diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 1467ae326..a4e69f988 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -16,7 +16,7 @@ import traceback import uuid import zipfile from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence +from typing import Any, Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit import aiofiles @@ -84,6 +84,14 @@ VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = { } InstalledPluginsProvider = Callable[[], List[str]] +PluginInstallGateway = Callable[ + [str, str, Optional[str], Optional[str], bool], + Tuple[bool, str], +] +AsyncPluginInstallGateway = Callable[ + [str, str, Optional[str], Optional[str], bool], + Awaitable[Tuple[bool, str]], +] @dataclass(frozen=True) @@ -101,6 +109,34 @@ def _empty_installed_plugins() -> List[str]: _installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins + +def _unconfigured_plugin_install_gateway( + _pid: str, + _repo_url: str, + _package_version: Optional[str], + _release_version: Optional[str], + _force_install: bool, +) -> Tuple[bool, str]: + """在组合根尚未装配来源门禁时拒绝插件包写入。""" + return False, "插件安装服务尚未完成初始化" + + +async def _unconfigured_async_plugin_install_gateway( + _pid: str, + _repo_url: str, + _package_version: Optional[str], + _release_version: Optional[str], + _force_install: bool, +) -> Tuple[bool, str]: + """在组合根尚未装配来源门禁时拒绝异步插件包写入。""" + return False, "插件安装服务尚未完成初始化" + + +_plugin_install_gateway: PluginInstallGateway = _unconfigured_plugin_install_gateway +_async_plugin_install_gateway: AsyncPluginInstallGateway = ( + _unconfigured_async_plugin_install_gateway +) + def configure_installed_plugins_provider( provider: InstalledPluginsProvider, ) -> None: @@ -109,6 +145,24 @@ def configure_installed_plugins_provider( _installed_plugins_provider = provider +def configure_plugin_install_gateway( + *, + install: PluginInstallGateway, + async_install: AsyncPluginInstallGateway, +) -> None: + """由启动组合根装配公开兼容安装入口的来源门禁。""" + global _plugin_install_gateway, _async_plugin_install_gateway + _plugin_install_gateway = install + _async_plugin_install_gateway = async_install + + +def reset_plugin_install_gateway() -> None: + """恢复未装配状态,供隔离测试清理进程级安装入口。""" + global _plugin_install_gateway, _async_plugin_install_gateway + _plugin_install_gateway = _unconfigured_plugin_install_gateway + _async_plugin_install_gateway = _unconfigured_async_plugin_install_gateway + + def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]: """规范化插件仓库地址,便于跨来源合并去重。""" repo_url = (repo_url or "").strip().rstrip("/") @@ -518,6 +572,11 @@ class PluginHelper(metaclass=WeakSingleton): candidate["repo_order"] = repo_order candidate["repo_path"] = repo_path candidate["path"] = plugin_dir + candidate["repo_url"] = self.make_local_repo_url( + pid, + repo_path, + package_version or None, + ) self.annotate_plugin_system_version(candidate) candidate_version = str(candidate.get("version") or "0") @@ -583,6 +642,11 @@ class PluginHelper(metaclass=WeakSingleton): candidate["repo_order"] = repo_order candidate["repo_path"] = local_repo_path candidate["path"] = plugin_dir + candidate["repo_url"] = self.make_local_repo_url( + candidate_pid, + local_repo_path, + current_package_version or None, + ) if not is_compatible: candidate["compatible"] = False candidate["skip_reason"] = ( @@ -798,11 +862,34 @@ class PluginHelper(metaclass=WeakSingleton): releases.extend(cls.__normalize_plugin_release_response(payload)) return len(payload) >= 100 + @cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名 + def get_plugin_index_result( + self, + repo_url: str, + package_version: Optional[str] = None, + ) -> Optional[Dict[str, Dict[str, Any]]]: + """读取插件索引;404 返回 None,读取失败由调用方记录。""" + request = self._build_plugin_index_request(repo_url, package_version) + if request is None: + raise ValueError("插件仓库地址无效") + package_url, headers = request + res = self.__request_with_fallback(package_url, headers=headers) + if res is None: + raise RuntimeError("插件索引请求失败:连接失败") + if res.status_code == 404: + return None + if res.status_code != 200: + raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}") + payload = self.__parse_plugin_index_response(res.text) + if payload is None: + raise RuntimeError("插件索引响应格式无效") + return payload + @cached(maxsize=128, ttl=1800) def get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: """ - 获取Github所有最新插件列表 + 获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。 :param repo_url: Github仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 """ @@ -955,16 +1042,32 @@ class PluginHelper(metaclass=WeakSingleton): release_version: Optional[str] = None, force_install: bool = False) \ -> Tuple[bool, str]: """ - 安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。 + 通过宿主统一 Gateway 安装插件,保留第三方插件使用的同步兼容 API。 :param pid: 插件 ID :param repo_url: 插件仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本 - :param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作 + :param force_install: 是否替换已存在的插件载荷 :return: (是否成功, 错误信息) """ + return _plugin_install_gateway( + pid, + repo_url, + package_version, + release_version, + force_install, + ) + + def __install_package(self, pid: str, repo_url: str, package_version: Optional[str] = None, + release_version: Optional[str] = None, force_install: bool = False) \ + -> Tuple[bool, str]: + """执行已通过来源准入的同步包安装,不负责身份或运行态提交。""" if self.is_local_repo_url(repo_url): - return self.install_local(pid=pid, repo_url=repo_url, force_install=force_install) + return self.__install_local_package( + pid=pid, + repo_url=repo_url, + force_install=force_install, + ) if SystemUtils.is_frozen(): return False, "可执行文件模式下,只能安装本地插件" @@ -1045,8 +1148,24 @@ class PluginHelper(metaclass=WeakSingleton): return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url) def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]: + """通过宿主统一 Gateway 安装本地插件。""" + target_repo = repo_url or self.make_local_repo_url(pid) + return _plugin_install_gateway( + pid, + target_repo, + self.parse_local_repo_package_version(target_repo), + None, + force_install, + ) + + def __install_local_package( + self, + pid: str, + repo_url: str = "", + force_install: bool = False, + ) -> Tuple[bool, str]: """ - 从本地插件仓库目录安装插件 + 执行已通过来源准入的本地插件包安装。 """ local_pid = self.parse_local_repo_url(repo_url) if repo_url else pid if not local_pid or local_pid.lower() != pid.lower(): @@ -2158,7 +2277,6 @@ class PluginHelper(metaclass=WeakSingleton): logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装") return False, dep_msg - self.refresh_persistent_plugin_backup(pid) if backup_dir: shutil.rmtree(backup_dir, ignore_errors=True) return True, "" @@ -2433,11 +2551,37 @@ class PluginHelper(metaclass=WeakSingleton): logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置") return None + @cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名 + async def async_get_plugin_index_result( + self, + repo_url: str, + package_version: Optional[str] = None, + ) -> Optional[Dict[str, Dict[str, Any]]]: + """异步读取插件索引;404 返回 None,读取失败由调用方记录。""" + request = self._build_plugin_index_request(repo_url, package_version) + if request is None: + raise ValueError("插件仓库地址无效") + package_url, headers = request + res = await self.__async_request_with_fallback( + package_url, + headers=headers, + ) + if res is None: + raise RuntimeError("插件索引请求失败:连接失败") + if res.status_code == 404: + return None + if res.status_code != 200: + raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}") + payload = self.__parse_plugin_index_response(res.text) + if payload is None: + raise RuntimeError("插件索引响应格式无效") + return payload + @cached(maxsize=128, ttl=1800) async def async_get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: """ - 异步获取Github所有最新插件列表 + 异步获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。 :param repo_url: Github仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本 """ @@ -3042,17 +3186,34 @@ class PluginHelper(metaclass=WeakSingleton): release_version: Optional[str] = None, force_install: bool = False) -> Tuple[bool, str]: """ - 异步安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。 + 通过宿主统一 Gateway 安装插件,保留第三方插件使用的异步兼容 API。 :param pid: 插件 ID :param repo_url: 插件仓库地址 :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本 - :param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作 + :param force_install: 是否替换已存在的插件载荷 :return: (是否成功, 错误信息) """ + return await _async_plugin_install_gateway( + pid, + repo_url, + package_version, + release_version, + force_install, + ) + + async def __async_install_package( + self, + pid: str, + repo_url: str, + package_version: Optional[str] = None, + release_version: Optional[str] = None, + force_install: bool = False, + ) -> Tuple[bool, str]: + """执行已通过来源准入的异步包安装,不负责身份或运行态提交。""" if self.is_local_repo_url(repo_url): return await _await_thread_operation( - self.install_local, + self.__install_local_package, pid, repo_url, force_install, @@ -3187,7 +3348,6 @@ class PluginHelper(metaclass=WeakSingleton): logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") return False, dep_msg - await _await_thread_operation(self.refresh_persistent_plugin_backup, pid) return True, "" except asyncio.CancelledError: logger.warning( diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index 7b85569a5..bbb2942e6 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, cast from app.adapters.external.market import PluginHelper as _PluginHelper from app.runtime.cache import async_fresh, fresh @@ -21,7 +21,7 @@ class PluginMarketClient: repo_url: str, package_version: Optional[str] = None, force: bool = False, - ) -> Optional[dict[str, dict]]: + ) -> Optional[dict[str, dict[str, Any]]]: """同步读取指定仓库和代际的插件索引。""" with fresh(force): return self._helper.get_plugins(repo_url, package_version) @@ -36,6 +36,35 @@ class PluginMarketClient: async with async_fresh(force): return await self._helper.async_get_plugins(repo_url, package_version) + def get_plugin_index_result( + self, + repo_url: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> Optional[dict[str, dict]]: + """读取插件索引的三态结果,供库存读取保留失败事实。""" + with fresh(force): + return cast( + Optional[dict[str, dict[str, Any]]], + self._helper.get_plugin_index_result(repo_url, package_version), + ) + + async def async_get_plugin_index_result( + self, + repo_url: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> Optional[dict[str, dict[str, Any]]]: + """异步读取插件索引的三态结果,供库存读取保留失败事实。""" + async with async_fresh(force): + return cast( + Optional[dict[str, dict[str, Any]]], + await self._helper.async_get_plugin_index_result( + repo_url, + package_version, + ), + ) + def get_local_candidates(self) -> dict[str, dict]: """返回全部本地插件仓库候选。""" return self._helper.get_local_plugin_candidates() diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index d48bebe23..edcaf130f 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -2,32 +2,47 @@ from __future__ import annotations +import hashlib import re import shutil import uuid from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Any, Optional, cast from app.adapters.external.market import PluginHelper as _PluginHelper +from app.adapters.system.host import SystemUtils from app.runtime.execution import ( run_in_threadpool_to_completion as _await_thread_operation, ) from app.runtime.log import logger from app.runtime.settings import RuntimeSettingsCompat - # 保留旧模块级入口,插件本地同步测试和旧扩展仍可能覆盖这些设置。 settings = RuntimeSettingsCompat() @dataclass(frozen=True, slots=True) class PluginPackageCheckpoint: - """记录一次插件包变更前可用于补偿恢复的文件快照。""" + """记录运行目录快照及待提升的容器恢复备份。""" plugin_id: str plugin_dir: Path + persistent_backup_dir: Path + backup_staging_dir: Path | None + backup_previous_dir: Path | None transaction_dir: Path - existed: bool + plugin_existed: bool + persistent_backup_existed: bool + + @property + def existed(self) -> bool: + """保留旧调用方读取运行目录存在状态的兼容属性。""" + return self.plugin_existed + + @property + def rollback_marker(self) -> Path: + """返回文件补偿完成标记,供 PREPARED 重放保持幂等。""" + return self.transaction_dir / ".rollback-complete" class PluginPackageManager: @@ -40,7 +55,7 @@ class PluginPackageManager: self._helper = helper or _PluginHelper() @staticmethod - def _plugin_dir(plugin_id: str) -> Path: + def __plugin_dir(plugin_id: str) -> Path: """解析插件运行目录并拒绝越出宿主插件根目录的标识。""" plugins_root = ( Path(settings.ROOT_PATH) / "app" / "plugins" @@ -50,18 +65,44 @@ class PluginPackageManager: raise ValueError(f"非法插件ID:{plugin_id}") return plugin_dir - def checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint: - """在包变更前创建独立快照,供后续提交或补偿恢复。""" - plugin_dir = self._plugin_dir(plugin_id) - transaction_dir = ( - Path(settings.TEMP_PATH) - / "plugin_transactions" - / f"{plugin_id.lower()}-{uuid.uuid4().hex}" + def checkpoint( + self, + plugin_id: str, + transaction_id: Optional[str] = None, + ) -> PluginPackageCheckpoint: + """在包变更前保存运行目录;持久事务使用配置目录承载恢复材料。""" + plugin_dir = self.__plugin_dir(plugin_id) + durable = transaction_id is not None + persistent_backup_dir = ( + Path(settings.CONFIG_PATH) / "plugins_backup" / plugin_id.lower() + ).resolve() + backup_staging_dir = ( + persistent_backup_dir.parent + / f".{plugin_id.lower()}.staging-{transaction_id}" + if durable and SystemUtils.is_docker() + else None ) - existed = plugin_dir.exists() + backup_previous_dir = ( + persistent_backup_dir.parent + / f".{plugin_id.lower()}.previous-{transaction_id}" + if durable and SystemUtils.is_docker() + else None + ) + transaction_root = ( + Path(settings.CONFIG_PATH) + if durable + else Path(settings.TEMP_PATH) + ) + transaction_dir = ( + transaction_root + / "plugin_transactions" + / (transaction_id or f"{plugin_id.lower()}-{uuid.uuid4().hex}") + ) + plugin_existed = plugin_dir.exists() + persistent_backup_existed = persistent_backup_dir.exists() try: transaction_dir.mkdir(parents=True, exist_ok=False) - if existed: + if plugin_existed: shutil.copytree(plugin_dir, transaction_dir / "package") except Exception: shutil.rmtree(transaction_dir, ignore_errors=True) @@ -69,18 +110,77 @@ class PluginPackageManager: return PluginPackageCheckpoint( plugin_id=plugin_id, plugin_dir=plugin_dir, + persistent_backup_dir=persistent_backup_dir, + backup_staging_dir=backup_staging_dir, + backup_previous_dir=backup_previous_dir, transaction_dir=transaction_dir, - existed=existed, + plugin_existed=plugin_existed, + persistent_backup_existed=persistent_backup_existed, ) - async def async_checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint: + def restore_checkpoint( + self, + *, + plugin_id: str, + transaction_id: str, + plugin_existed: bool, + persistent_backup_existed: bool, + ) -> PluginPackageCheckpoint: + """按受控根目录和事务 ID 重建崩溃回放所需的文件引用。""" + plugin_dir = self.__plugin_dir(plugin_id) + persistent_backup_dir = ( + Path(settings.CONFIG_PATH) / "plugins_backup" / plugin_id.lower() + ).resolve() + durable_backup = SystemUtils.is_docker() + return PluginPackageCheckpoint( + plugin_id=plugin_id, + plugin_dir=plugin_dir, + persistent_backup_dir=persistent_backup_dir, + backup_staging_dir=( + persistent_backup_dir.parent + / f".{plugin_id.lower()}.staging-{transaction_id}" + if durable_backup + else None + ), + backup_previous_dir=( + persistent_backup_dir.parent + / f".{plugin_id.lower()}.previous-{transaction_id}" + if durable_backup + else None + ), + transaction_dir=( + Path(settings.CONFIG_PATH) + / "plugin_transactions" + / transaction_id + ), + plugin_existed=plugin_existed, + persistent_backup_existed=persistent_backup_existed, + ) + + async def async_checkpoint( + self, + plugin_id: str, + transaction_id: Optional[str] = None, + ) -> PluginPackageCheckpoint: """在线程池中创建插件包文件快照。""" - return await _await_thread_operation(self.checkpoint, plugin_id) + return cast( + PluginPackageCheckpoint, + await _await_thread_operation( + self.checkpoint, + plugin_id, + transaction_id, + ), + ) @staticmethod def commit(checkpoint: PluginPackageCheckpoint) -> None: - """确认包变更成功并清理临时快照。""" - shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False) + """清理已完成事务的运行目录快照和残余替换材料。""" + if checkpoint.backup_staging_dir and checkpoint.backup_staging_dir.exists(): + raise RuntimeError("持久备份尚未提升,不能清理插件安装事务") + if checkpoint.backup_previous_dir and checkpoint.backup_previous_dir.exists(): + raise RuntimeError("旧持久备份尚未清理,不能结束插件安装事务") + if checkpoint.transaction_dir.exists(): + shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False) async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None: """在线程池中清理已提交的插件包快照。""" @@ -88,17 +188,30 @@ class PluginPackageManager: @staticmethod def rollback(checkpoint: PluginPackageCheckpoint) -> None: - """删除当前包并把变更前文件快照恢复到运行目录。""" - snapshot_dir = checkpoint.transaction_dir / "package" - if checkpoint.existed: - if not snapshot_dir.is_dir(): - raise FileNotFoundError( - f"插件 {checkpoint.plugin_id} 的补偿快照不存在:{snapshot_dir}" - ) - if checkpoint.plugin_dir.exists(): - shutil.rmtree(checkpoint.plugin_dir) - if checkpoint.existed: - shutil.copytree(snapshot_dir, checkpoint.plugin_dir) + """兼容旧调用方,恢复运行目录和持久备份后清理恢复材料。""" + PluginPackageManager.restore(checkpoint) + PluginPackageManager.cleanup(checkpoint) + + @staticmethod + def restore(checkpoint: PluginPackageCheckpoint) -> None: + """恢复运行目录和提交前持久备份,并保留快照直到 journal 删除。""" + if checkpoint.rollback_marker.is_file(): + return + PluginPackageManager.__restore_tree( + target=checkpoint.plugin_dir, + snapshot=checkpoint.transaction_dir / "package", + existed=checkpoint.plugin_existed, + label=f"插件 {checkpoint.plugin_id} 运行目录", + ) + PluginPackageManager.__rollback_persistent_backup(checkpoint) + if checkpoint.backup_staging_dir and checkpoint.backup_staging_dir.exists(): + shutil.rmtree(checkpoint.backup_staging_dir, ignore_errors=False) + checkpoint.transaction_dir.mkdir(parents=True, exist_ok=True) + checkpoint.rollback_marker.touch(exist_ok=True) + + @staticmethod + def cleanup(checkpoint: PluginPackageCheckpoint) -> None: + """在 journal 已删除后清理恢复材料;重复调用保持幂等。""" if checkpoint.transaction_dir.exists(): shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False) @@ -106,6 +219,243 @@ class PluginPackageManager: """在线程池中恢复插件包文件快照。""" await _await_thread_operation(self.rollback, checkpoint) + async def async_restore(self, checkpoint: PluginPackageCheckpoint) -> None: + """在线程池恢复插件状态,并保留 journal 仍需引用的材料。""" + await _await_thread_operation(self.restore, checkpoint) + + async def async_cleanup(self, checkpoint: PluginPackageCheckpoint) -> None: + """在线程池清理已失去 journal 所有权的恢复材料。""" + await _await_thread_operation(self.cleanup, checkpoint) + + @staticmethod + def __rollback_persistent_backup( + checkpoint: PluginPackageCheckpoint, + ) -> None: + """把已激活但尚未提交的持久备份恢复到事务前状态。""" + previous = checkpoint.backup_previous_dir + staging = checkpoint.backup_staging_dir + if previous is None or staging is None: + return + + target = checkpoint.persistent_backup_dir + if previous.exists(): + discarded = target.parent / f".{target.name}.discard-{uuid.uuid4().hex}" + try: + if target.exists(): + target.replace(discarded) + previous.replace(target) + if discarded.exists(): + shutil.rmtree(discarded, ignore_errors=False) + except Exception: + if not target.exists() and discarded.exists(): + discarded.replace(target) + raise + finally: + if target.exists() and discarded.exists(): + shutil.rmtree(discarded, ignore_errors=True) + return + + if staging.exists(): + return + if checkpoint.persistent_backup_existed: + if target.exists(): + return + raise FileNotFoundError( + f"插件 {checkpoint.plugin_id} 的旧持久备份恢复材料不存在" + ) + if target.exists(): + shutil.rmtree(target, ignore_errors=False) + + @staticmethod + def __restore_tree( + *, + target: Path, + snapshot: Path, + existed: bool, + label: str, + ) -> None: + """用同级 staging 替换目录,失败时保留替换前的当前目录。""" + if existed and not snapshot.is_dir(): + raise FileNotFoundError(f"{label}补偿快照不存在:{snapshot}") + + target.parent.mkdir(parents=True, exist_ok=True) + staging = target.parent / f".{target.name}.restore-{uuid.uuid4().hex}" + previous = target.parent / f".{target.name}.previous-{uuid.uuid4().hex}" + try: + if existed: + shutil.copytree(snapshot, staging) + if target.exists(): + target.replace(previous) + if existed: + staging.replace(target) + if previous.exists(): + shutil.rmtree(previous) + except Exception: + if not target.exists() and previous.exists(): + previous.replace(target) + raise + finally: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + if target.exists() and previous.exists(): + shutil.rmtree(previous, ignore_errors=True) + + @classmethod + def stage_persistent_backup(cls, checkpoint: PluginPackageCheckpoint) -> None: + """把新载荷复制到持久配置目录的独立 staging,不覆盖现有备份。""" + staging = checkpoint.backup_staging_dir + if staging is None: + return + if not checkpoint.plugin_dir.is_dir(): + raise FileNotFoundError( + f"插件 {checkpoint.plugin_id} 运行目录不存在" + ) + staging.parent.mkdir(parents=True, exist_ok=True) + if staging.exists(): + shutil.rmtree(staging, ignore_errors=False) + shutil.copytree( + checkpoint.plugin_dir, + staging, + ignore=shutil.ignore_patterns(*cls._COPY_IGNORE), + ) + + async def async_stage_persistent_backup( + self, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """在线程池准备新载荷的容器恢复备份。""" + await _await_thread_operation(self.stage_persistent_backup, checkpoint) + + @staticmethod + def activate_persistent_backup(checkpoint: PluginPackageCheckpoint) -> None: + """在数据库提交前激活新备份,并保留上一份备份供失败补偿。""" + staging = checkpoint.backup_staging_dir + previous = checkpoint.backup_previous_dir + if staging is None or previous is None: + return + + target = checkpoint.persistent_backup_dir + target.parent.mkdir(parents=True, exist_ok=True) + if staging.exists(): + if target.exists() and not previous.exists(): + target.replace(previous) + if not target.exists(): + staging.replace(target) + elif not target.exists(): + raise FileNotFoundError( + f"插件 {checkpoint.plugin_id} 的持久备份 staging 不存在" + ) + + async def async_activate_persistent_backup( + self, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """在线程池激活新持久备份,同时保留失败补偿材料。""" + await _await_thread_operation(self.activate_persistent_backup, checkpoint) + + @staticmethod + def finalize_persistent_backup(checkpoint: PluginPackageCheckpoint) -> None: + """数据库提交后清理上一份持久备份;重复调用保持幂等。""" + staging = checkpoint.backup_staging_dir + previous = checkpoint.backup_previous_dir + if staging is None or previous is None: + return + if staging.exists(): + raise RuntimeError("新持久备份尚未激活") + if not checkpoint.persistent_backup_dir.is_dir(): + raise FileNotFoundError( + f"插件 {checkpoint.plugin_id} 的已提交持久备份不存在" + ) + if previous.exists(): + shutil.rmtree(previous, ignore_errors=False) + + async def async_finalize_persistent_backup( + self, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """在线程池清理数据库提交后的旧持久备份。""" + await _await_thread_operation(self.finalize_persistent_backup, checkpoint) + + def payload_receipt(self, plugin_id: str) -> str: + """按稳定相对路径和文件内容计算已安装载荷收据。""" + plugin_dir = self.__plugin_dir(plugin_id) + if not plugin_dir.is_dir(): + raise FileNotFoundError(f"插件 {plugin_id} 运行目录不存在") + return self.__tree_receipt(plugin_dir) + + @classmethod + def persistent_backup_receipt( + cls, + checkpoint: PluginPackageCheckpoint, + ) -> str: + """计算已提升持久备份的内容收据,供崩溃回放确认终态。""" + if not checkpoint.persistent_backup_dir.is_dir(): + raise FileNotFoundError( + f"插件 {checkpoint.plugin_id} 持久备份不存在" + ) + return cls.__tree_receipt(checkpoint.persistent_backup_dir) + + @classmethod + def __tree_receipt(cls, root: Path) -> str: + """对插件目录使用稳定路径和文件内容生成审计收据。""" + + digest = hashlib.sha256() + for path in sorted( + root.rglob("*"), + key=lambda item: item.relative_to(root).as_posix(), + ): + relative = path.relative_to(root).as_posix() + if cls.__ignored_receipt_path(path, root): + continue + encoded_path = relative.encode("utf-8") + digest.update(len(encoded_path).to_bytes(4, "big")) + digest.update(encoded_path) + if path.is_symlink(): + digest.update(b"L") + target = path.readlink().as_posix().encode("utf-8") + digest.update(len(target).to_bytes(4, "big")) + digest.update(target) + elif path.is_dir(): + digest.update(b"D") + elif path.is_file(): + digest.update(b"F") + with path.open("rb") as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b""): + digest.update(chunk) + return f"sha256:{digest.hexdigest()}" + + async def async_payload_receipt(self, plugin_id: str) -> str: + """在线程池计算插件载荷收据。""" + return cast( + str, + await _await_thread_operation(self.payload_receipt, plugin_id), + ) + + async def async_committed_payload_receipt( + self, + checkpoint: PluginPackageCheckpoint, + ) -> str: + """读取数据库已提交载荷在当前部署模式下的恢复事实。""" + if checkpoint.backup_staging_dir is not None: + return cast( + str, + await _await_thread_operation( + self.persistent_backup_receipt, + checkpoint, + ), + ) + return await self.async_payload_receipt(checkpoint.plugin_id) + + @classmethod + def __ignored_receipt_path(cls, path: Path, root: Path) -> bool: + """排除不会进入运行载荷和持久备份的派生文件。""" + relative_parts = path.relative_to(root).parts + return any( + part in {"__pycache__", "node_modules", ".DS_Store"} + or part.endswith(".pyc") + for part in relative_parts + ) + def install( self, plugin_id: str, @@ -115,12 +465,15 @@ class PluginPackageManager: force_install: bool = False, ) -> tuple[bool, str]: """同步安装插件包,下载过程继续复用既有市场兼容策略。""" - return self._helper.install( - pid=plugin_id, - repo_url=repo_url, - package_version=package_version, - release_version=release_version, - force_install=force_install, + return cast( + tuple[bool, str], + cast(Any, self._helper)._PluginHelper__install_package( + pid=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force_install=force_install, + ), ) async def async_install( @@ -132,18 +485,21 @@ class PluginPackageManager: force_install: bool = False, ) -> tuple[bool, str]: """异步安装插件包,下载过程继续复用既有市场兼容策略。""" - return await self._helper.async_install( - pid=plugin_id, - repo_url=repo_url, - package_version=package_version, - release_version=release_version, - force_install=force_install, + return cast( + tuple[bool, str], + await cast(Any, self._helper)._PluginHelper__async_install_package( + pid=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force_install=force_install, + ), ) def sync_local(self, plugin_id: str, source_dir: Path) -> bool: """用本地仓库内容原子替换运行副本,失败时恢复原目录。""" source_dir = source_dir.resolve() - plugin_dir = self._plugin_dir(plugin_id) + plugin_dir = self.__plugin_dir(plugin_id) if source_dir == plugin_dir: return True checkpoint = self.checkpoint(plugin_id) @@ -181,8 +537,8 @@ class PluginPackageManager: icon: Optional[str] = None, ) -> tuple[bool, str]: """复制并改写插件分身文件,任一步失败都删除不完整目标。""" - original_dir = self._plugin_dir(plugin_id) - clone_dir = self._plugin_dir(clone_id) + original_dir = self.__plugin_dir(plugin_id) + clone_dir = self.__plugin_dir(clone_id) if not original_dir.is_dir(): return False, f"原插件目录 {original_dir} 不存在" if clone_dir.exists(): diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 3a356e2a8..4807890fa 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -2,22 +2,19 @@ import json import shutil -from contextvars import copy_context from pathlib import Path from typing import Any, Optional -from app.runtime.settings import RuntimeSettingsCompat - -settings = RuntimeSettingsCompat() -from app.application.plugin.runtime import get_plugin_manager -from app.application.plugin.install import PluginInstallCommand -from app.application.configuration import get_configured_system_config -from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.market import PluginHelper -from app.adapters.system.plugin.package import PluginPackageManager +from app.application.configuration import get_configured_system_config +from app.application.plugin.gateway import get_plugin_install_service +from app.application.plugin.runtime import get_plugin_manager +from app.runtime.settings import RuntimeSettingsCompat from app.schemas.plugin import PluginRuntimeStatus from app.schemas.types import SystemConfigKey +settings = RuntimeSettingsCompat() + # 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。 DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000 MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000 @@ -314,97 +311,48 @@ def summarize_candidates(matches: list[dict[str, Any]], limit: int = DEFAULT_PLU async def install_plugin_runtime( - plugin_id: str, repo_url: Optional[str], force: bool = False + plugin_id: str, + repo_url: Optional[str], + force: bool = False, + *, + explicit_source: bool = False, ) -> tuple[bool, str, bool]: """ 按现有插件接口的行为安装插件,并刷新运行态注册信息。 """ - plugin_manager = get_plugin_manager() - plugin_helper = PluginHelper() - package_manager = PluginPackageManager(plugin_helper) - - from app.agent.tools.base import run_agent_blocking - - async def save_installed_plugins(plugin_ids: list[str]) -> object: - """保存智能体安装用例确认后的插件列表。""" - return await get_configured_system_config().async_set( - SystemConfigKey.UserInstalledPlugins, - plugin_ids, - ) - - async def install_package( - target_id: str, - target_repo: str, - _release_version: Optional[str], - force_install: bool, - ) -> tuple[bool, str]: - """调用插件包适配器执行异步安装。""" - return await package_manager.async_install( - plugin_id=target_id, - repo_url=target_repo, - force_install=force_install, - ) - - async def skip_compatibility_check( - _target_id: str, - _target_repo: str, - ) -> None: - """保持 Agent 旧安装入口不额外执行系统版本预检查。""" - return None - - async def reload_runtime(target_id: str) -> object: - """通过 Agent 阻塞任务适配器重载源插件及其虚拟实例。""" - mutation_context = copy_context() - return await run_agent_blocking( - "plugin", - mutation_context.run, - plugin_manager.reload_plugin_tree, - target_id, - ) - - async def refresh_registrations(target_id: str) -> object: - """通过 Agent 阻塞任务适配器刷新源插件及其虚拟实例注册。""" - result = None - reload_targets = list( - plugin_manager.get_plugin_reload_targets(target_id) - ) or [target_id] - for reload_target in reload_targets: - result = await run_agent_blocking( - "plugin", - refresh_plugin_registrations, - reload_target, - ) - return result - - result = await PluginInstallCommand( - installed_plugins_reader=lambda: get_configured_system_config().get( - SystemConfigKey.UserInstalledPlugins - ) or [], - installed_plugins_writer=save_installed_plugins, - plugin_ids_provider=plugin_manager.get_plugin_ids, - compatibility_checker=skip_compatibility_check, - package_installer=install_package, - package_checkpointer=package_manager.async_checkpoint, - package_committer=package_manager.async_commit, - package_rollback=package_manager.async_rollback, - install_reporter=lambda target_id, target_repo: ( - MoviePilotServerHelper.async_install_plugin_reg( - plugin_id=target_id, - repo_url=target_repo, - ) - ), - plugin_reloader=reload_runtime, - registration_refresher=refresh_registrations, - mutation=plugin_manager.mutation, - package_write_guard=plugin_manager.suppress_plugin_monitor, - ).execute( + result = await get_plugin_install_service().install( plugin_id=plugin_id, - repo_url=repo_url, + repo_url=repo_url or None, force=force, + explicit_source=explicit_source, ) return result.success, result.message, result.refreshed_only +async def inspect_plugin_sources( + plugin_id: str, + *, + force: bool = False, +) -> dict[str, Any]: + """返回 Agent 可展示的脱敏来源候选与当前准入状态。""" + inspection = await get_plugin_install_service().inspect_source( + plugin_id=plugin_id, + force=force, + ) + candidates = [ + candidate.public_dict() + for candidate in inspection.online_candidates + ] + if inspection.local_candidate is not None: + candidates.append(inspection.local_candidate.public_dict()) + return { + "selection_status": inspection.selection.status.value, + "selection_reason": inspection.selection.reason, + "inventory_complete": inspection.inventory_complete, + "candidates": candidates, + } + + async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: """ 按现有卸载逻辑移除插件,并清理运行态注册与分组信息。 diff --git a/app/agent/tools/impl/install_plugin.py b/app/agent/tools/impl/install_plugin.py index 6074f819d..f0bfa9c49 100644 --- a/app/agent/tools/impl/install_plugin.py +++ b/app/agent/tools/impl/install_plugin.py @@ -3,12 +3,13 @@ import json from typing import Optional, Type -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.agent.tools.impl._plugin_tool_utils import ( get_plugin_snapshot, + inspect_plugin_sources, install_plugin_runtime, load_market_plugins, summarize_plugin, @@ -31,6 +32,26 @@ class InstallPluginInput(BaseModel): False, description="Whether to refresh plugin market caches before reading the market list.", ) + repo_url: Optional[str] = Field( + None, + description=( + "Exact repository URL explicitly selected by the administrator. " + "Only set it after a source conflict is shown to the user." + ), + ) + + @field_validator("repo_url") + @classmethod + def normalize_repo_url(cls, value: Optional[str]) -> Optional[str]: + """显式来源必须是非空在线仓库地址。""" + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("Explicit source repository URL cannot be empty.") + if normalized.startswith("local://"): + raise ValueError("Explicit source selection only accepts online repositories.") + return normalized class InstallPluginTool(MoviePilotTool): @@ -56,6 +77,7 @@ class InstallPluginTool(MoviePilotTool): plugin_id: str, force: bool = False, force_refresh_market: bool = False, + repo_url: Optional[str] = None, **kwargs, ) -> str: logger.info( @@ -82,10 +104,33 @@ class InstallPluginTool(MoviePilotTool): success, message, refreshed_only = await install_plugin_runtime( candidate.id, - getattr(candidate, "repo_url", None), + repo_url, force=force, + explicit_source=repo_url is not None, ) if not success: + source_options = await inspect_plugin_sources( + candidate.id, + force=False, + ) + if ( + repo_url is None + and source_options["selection_status"] in { + "conflict", + "incomplete", + } + ): + return json.dumps( + { + "success": False, + "plugin": summarize_plugin(candidate), + "message": source_options["selection_reason"], + "requires_explicit_source": True, + "source_candidates": source_options["candidates"], + }, + ensure_ascii=False, + indent=2, + ) return json.dumps( { "success": False, diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index e9f38f0d7..e846096ec 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -11,7 +11,6 @@ from starlette.responses import StreamingResponse from app.adapters.external.market import PluginHelper from app.adapters.external.server import MoviePilotServerHelper -from app.adapters.system.plugin.package import PluginPackageManager from app.adapters.web.security.access import ( resource_token_cookie, verify_resource_token, @@ -31,12 +30,12 @@ from app.application.commands import init_commands from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config from app.application.plugin.config import PluginConfigCommand from app.application.plugin.folders import remove_plugin_from_folders -from app.application.plugin.install import PluginInstallCommand +from app.application.plugin.gateway import get_plugin_install_service from app.application.plugin.routes import register_plugin_api, remove_plugin_api from app.application.plugin.runtime import PluginRuntime, get_plugin_manager +from app.application.plugin.transaction import get_plugin_persistence from app.application.scheduling import remove_plugin_job, update_plugin_job from app.runtime.cache import async_fresh -from app.runtime.execution import run_in_threadpool from app.runtime.extensions.plugin.contracts import ( PluginDashboardError, PluginNotFoundError, @@ -61,6 +60,11 @@ from app.schemas.plugin import PluginRemoteInfo as _SchemaPluginRemoteInfo from app.schemas.plugin import PluginRuntimeStatus as _SchemaPluginRuntimeStatus from app.schemas.plugin import PluginRuntimeSummary as _SchemaPluginRuntimeSummary from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem +from app.schemas.plugin import PluginSourceCandidate as _SchemaPluginSourceCandidate +from app.schemas.plugin import PluginSourceChangeRequest as _SchemaPluginSourceChangeRequest +from app.schemas.plugin import PluginSourceIdentity as _SchemaPluginSourceIdentity +from app.schemas.plugin import PluginSourceInstallRequest as _SchemaPluginSourceInstallRequest +from app.schemas.plugin import PluginSourceOptions as _SchemaPluginSourceOptions from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import SystemConfigKey @@ -69,6 +73,19 @@ router = ResponseAPIRouter() _plugin_release_refresh_tasks: set[asyncio.Task] = set() +def _plugin_source_identity_schema(identity: Any) -> _SchemaPluginSourceIdentity: + """把持久化身份映射为公共来源确认 DTO。""" + return _SchemaPluginSourceIdentity( + plugin_id=identity.plugin_id, + trusted_source_type=identity.trusted_source_type.value, + trusted_source_key=identity.trusted_source_key, + binding_basis=identity.binding_basis.value, + payload_source_type=identity.payload_source_type.value, + payload_source_key=identity.payload_source_key, + revision=identity.revision, + ) + + async def _get_market_plugin_from_repo( plugin_manager: PluginRuntime, plugin_id: str, @@ -554,69 +571,121 @@ async def install( """ 安装插件 """ - plugin_helper = PluginHelper() - package_manager = PluginPackageManager(plugin_helper) - plugin_manager = get_plugin_manager() - - async def save_installed_plugins(plugin_ids: List[str]) -> object: - """保存安装用例确认后的插件列表。""" - return await get_configured_system_config().async_set( - SystemConfigKey.UserInstalledPlugins, - plugin_ids, - ) - - async def install_package( - target_id: str, - target_repo: str, - target_release: Optional[str], - force_install: bool, - ) -> tuple[bool, str]: - """调用插件包适配器执行异步安装。""" - return await package_manager.async_install( - plugin_id=target_id, - repo_url=target_repo, - release_version=target_release, - force_install=force_install, - ) - - async def reload_runtime(target_id: str) -> object: - """在线程池中重建源插件及其全部虚拟实例。""" - return await run_in_threadpool( - get_plugin_manager().reload_plugin_tree, target_id - ) - - async def refresh_registrations(target_id: str) -> object: - """在线程池中刷新源插件及其虚拟实例的全部宿主注册。""" - for reload_target in plugin_manager.get_plugin_reload_targets(target_id): - await run_in_threadpool(register_plugin, reload_target) - - command = PluginInstallCommand( - installed_plugins_reader=lambda: get_configured_system_config().get( - SystemConfigKey.UserInstalledPlugins - ) or [], - installed_plugins_writer=save_installed_plugins, - plugin_ids_provider=lambda: get_plugin_manager().get_plugin_ids(), - compatibility_checker=plugin_helper.async_get_plugin_system_version_check_message, - package_installer=install_package, - package_checkpointer=package_manager.async_checkpoint, - package_committer=package_manager.async_commit, - package_rollback=package_manager.async_rollback, - install_reporter=lambda target_id, target_repo: ( - MoviePilotServerHelper.async_install_plugin_reg( - plugin_id=target_id, - repo_url=target_repo, - ) - ), - plugin_reloader=reload_runtime, - registration_refresher=refresh_registrations, - mutation=plugin_manager.mutation, - package_write_guard=plugin_manager.suppress_plugin_monitor, - ) - result = await command.execute( + result = await get_plugin_install_service().install( plugin_id=plugin_id, - repo_url=repo_url, + repo_url=None, release_version=release_version, force=bool(force), + explicit_source=False, + ) + if not result.success: + return _SchemaResponse(success=False, message=result.message) + return _SchemaResponse(success=True) + + +@router.get( + "/source/{plugin_id}", + summary="获取插件来源身份", + response_model=_SchemaResponse[_SchemaPluginSourceIdentity], +) +async def get_plugin_source_identity( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """返回显式换源确认所需的当前可信来源和 revision。""" + identity = await get_plugin_persistence().get_identity(plugin_id) + if identity is None: + return _SchemaResponse(success=False, message="插件来源身份不存在") + return _SchemaResponse( + success=True, + data=_plugin_source_identity_schema(identity), + ) + + +@router.get( + "/source/{plugin_id}/options", + summary="获取插件来源候选", + response_model=_SchemaResponse[_SchemaPluginSourceOptions], +) +async def get_plugin_source_options( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), + force: bool = False, +) -> Any: + """返回与真实安装相同库存中的脱敏候选和当前准入状态。""" + inspection = await get_plugin_install_service().inspect_source( + plugin_id=plugin_id, + force=force, + ) + candidates = [ + _SchemaPluginSourceCandidate.model_validate(candidate.public_dict()) + for candidate in inspection.online_candidates + ] + if inspection.local_candidate is not None: + candidates.append( + _SchemaPluginSourceCandidate.model_validate( + inspection.local_candidate.public_dict() + ) + ) + return _SchemaResponse( + success=True, + data=_SchemaPluginSourceOptions( + plugin_id=inspection.plugin_id, + inventory_complete=inspection.inventory_complete, + selection_status=inspection.selection.status.value, + selection_reason=inspection.selection.reason, + identity=( + _plugin_source_identity_schema(inspection.identity) + if inspection.identity is not None + else None + ), + candidates=candidates, + ), + ) + + +@router.post( + "/source/{plugin_id}/install", + summary="按明确来源安装插件", + response_model=_SchemaResponse[None], +) +async def install_plugin_from_source( + plugin_id: str, + request: _SchemaPluginSourceInstallRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """安装管理员明确选择的初始在线来源,不承担已绑定插件换源。""" + result = await get_plugin_install_service().install( + plugin_id=plugin_id, + repo_url=request.repo_url, + release_version=request.release_version, + force=request.force, + explicit_source=True, + ) + if not result.success: + return _SchemaResponse(success=False, message=result.message) + return _SchemaResponse(success=True) + + +@router.post( + "/source/{plugin_id}", + summary="切换插件来源", + response_model=_SchemaResponse[None], +) +async def change_plugin_source( + plugin_id: str, + request: _SchemaPluginSourceChangeRequest, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> Any: + """按精确身份 revision 安装明确选择的新在线来源。""" + result = await get_plugin_install_service().install( + plugin_id=plugin_id, + repo_url=request.repo_url, + release_version=request.release_version, + force=True, + explicit_source=True, + source_change=True, + expected_revision=request.expected_revision, ) if not result.success: return _SchemaResponse(success=False, message=result.message) diff --git a/app/application/plugin/admission.py b/app/application/plugin/admission.py new file mode 100644 index 000000000..12f7d9f85 --- /dev/null +++ b/app/application/plugin/admission.py @@ -0,0 +1,227 @@ +"""插件载荷来源准入与目标身份规划。""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + TrustedPluginSourceType, +) +from app.application.plugin.inventory import normalize_github_plugin_source +from app.application.plugin.source import ( + Candidate, + CandidateInventory, + PluginLocalCandidate, + PluginSelectionStatus, + parse_local_plugin_reference, + select_plugin_candidate, +) + + +class PluginSourceAdmissionError(RuntimeError): + """插件来源冲突、库存不完整或换源授权无效。""" + + +@dataclass(frozen=True, slots=True) +class PluginInstallAdmissionRequest: + """一次安装调用中会影响来源选择的显式业务参数。""" + + plugin_id: str + generations: Sequence[str] + requested_repo_url: str | None = None + explicit_source: bool = False + source_change: bool = False + expected_revision: int | None = None + + +@dataclass(frozen=True, slots=True) +class PluginInstallAdmission: + """下载前冻结的候选与身份转换决策。""" + + candidate: Candidate + identity_before: PluginIdentity | None + binding_basis: PluginBindingBasis + trusted_source_type: TrustedPluginSourceType + trusted_source_key: str | None + bound_at: datetime | None + + @property + def expected_revision(self) -> int | None: + """返回最终数据库提交必须匹配的身份 revision。""" + return self.identity_before.revision if self.identity_before else None + + def build_identity( + self, + *, + payload_receipt: str, + applied_at: datetime, + declared_version: str | None = None, + ) -> PluginIdentity: + """在载荷落盘并生成收据后构造唯一数据库提交目标。""" + current = self.identity_before + plugin_id = current.plugin_id if current else self.candidate.plugin_id + metadata = self.candidate.dto if isinstance(self.candidate.dto, Mapping) else {} + system_version = metadata.get("system_version") + supports_v3 = metadata.get("v3") + supports_v3t = metadata.get("v3t") + source_binding_changed = ( + self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN + and ( + current is None + or current.trusted_source_type is TrustedPluginSourceType.UNKNOWN + or current.trusted_source_type is not self.trusted_source_type + or current.trusted_source_key != self.trusted_source_key + ) + ) + return PluginIdentity( + plugin_id=plugin_id, + normalized_plugin_id=plugin_id.lower(), + trusted_source_type=self.trusted_source_type, + trusted_source_key=self.trusted_source_key, + binding_basis=self.binding_basis, + payload_source_type=self.candidate.payload_source_type, + payload_source_key=( + None + if isinstance(self.candidate, PluginLocalCandidate) + else self.candidate.source_key + ), + declared_version=declared_version or self.candidate.plugin_version, + package_generation=self.candidate.package_generation, + system_version=( + system_version if isinstance(system_version, str) else None + ), + supports_v3=supports_v3 if isinstance(supports_v3, bool) else None, + supports_v3t=supports_v3t if isinstance(supports_v3t, bool) else None, + payload_receipt=payload_receipt, + revision=(current.revision + 1) if current else 1, + created_at=current.created_at if current else applied_at, + updated_at=applied_at, + bound_at=applied_at if source_binding_changed else self.bound_at, + payload_applied_at=applied_at, + ) + + +def admit_plugin_install( + inventory: CandidateInventory, + *, + request: PluginInstallAdmissionRequest, + identity: PluginIdentity | None, + now: datetime, +) -> PluginInstallAdmission: + """选择唯一允许载荷,并冻结最终身份转换的可信来源边界。""" + if now.tzinfo is None: + raise PluginSourceAdmissionError("插件安装准入时间必须包含时区") + if identity is not None and now < identity.updated_at: + raise PluginSourceAdmissionError("插件安装准入时间不能早于当前身份更新时间") + bound_at: datetime | None + if request.source_change: + if not request.explicit_source or not request.requested_repo_url: + raise PluginSourceAdmissionError("显式换源必须指定目标在线来源") + if request.requested_repo_url.startswith("local://"): + raise PluginSourceAdmissionError("显式换源只接受在线插件仓库") + if identity is None or identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN: + raise PluginSourceAdmissionError("显式换源要求插件已经绑定在线来源") + if request.expected_revision != identity.revision: + raise PluginSourceAdmissionError("显式换源的身份 revision 已失效") + elif request.expected_revision is not None: + raise PluginSourceAdmissionError("普通安装不能携带换源 revision") + + requested_source_key = None + local_candidates = None + if request.requested_repo_url: + if request.requested_repo_url.startswith("local://"): + referenced_plugin_id = parse_local_plugin_reference( + request.requested_repo_url + ) + if ( + referenced_plugin_id is None + or referenced_plugin_id.lower() != request.plugin_id.lower() + ): + raise PluginSourceAdmissionError( + "明确选择的本地来源与目标插件不一致" + ) + available_local_candidates = inventory.local_candidates_for( + request.plugin_id + ) + exact_candidates = tuple( + candidate + for candidate in available_local_candidates + if candidate.repo_url == request.requested_repo_url + ) + local_candidates = exact_candidates or available_local_candidates + if not local_candidates: + raise PluginSourceAdmissionError("明确选择的本地来源没有当前插件候选") + else: + requested_source_key, _repo_url = normalize_github_plugin_source( + request.requested_repo_url + ) + + selection = select_plugin_candidate( + inventory, + plugin_id=request.plugin_id, + generations=request.generations, + identity=identity, + local_candidates=local_candidates, + requested_source_key=requested_source_key, + explicit_source=request.explicit_source, + allow_source_change=request.source_change, + ) + if selection.status is not PluginSelectionStatus.SELECTED or selection.candidate is None: + raise PluginSourceAdmissionError(selection.reason or "插件来源准入失败") + candidate = selection.candidate + if not candidate.plugin_version: + raise PluginSourceAdmissionError("插件候选缺少可持久化的版本声明") + + if isinstance(candidate, PluginLocalCandidate): + if identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN: + return PluginInstallAdmission( + candidate=candidate, + identity_before=identity, + binding_basis=identity.binding_basis, + trusted_source_type=identity.trusted_source_type, + trusted_source_key=identity.trusted_source_key, + bound_at=identity.bound_at, + ) + return PluginInstallAdmission( + candidate=candidate, + identity_before=identity, + binding_basis=PluginBindingBasis.LOCAL_ONLY, + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + bound_at=None, + ) + + if request.source_change: + if ( + identity is not None + and identity.trusted_source_type is candidate.source_type + and identity.trusted_source_key == candidate.source_key + ): + raise PluginSourceAdmissionError("显式换源的目标必须不同于当前来源") + basis = PluginBindingBasis.EXPLICIT_SOURCE_CHANGE + bound_at = now + elif identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN: + basis = identity.binding_basis + bound_at = identity.bound_at + elif request.explicit_source: + basis = PluginBindingBasis.EXPLICIT_INSTALL + bound_at = now + elif candidate.source_type is TrustedPluginSourceType.OFFICIAL: + basis = PluginBindingBasis.OFFICIAL_DEFAULT + bound_at = now + else: + basis = PluginBindingBasis.TOFU + bound_at = now + + return PluginInstallAdmission( + candidate=candidate, + identity_before=identity, + binding_basis=basis, + trusted_source_type=candidate.source_type, + trusted_source_key=candidate.source_key, + bound_at=bound_at, + ) diff --git a/app/application/plugin/gateway.py b/app/application/plugin/gateway.py new file mode 100644 index 000000000..18df8ad9c --- /dev/null +++ b/app/application/plugin/gateway.py @@ -0,0 +1,199 @@ +"""统一插件安装 Gateway。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + +from app.application.plugin.admission import ( + PluginInstallAdmission, + PluginInstallAdmissionRequest, + PluginSourceAdmissionError, + admit_plugin_install, +) +from app.application.plugin.identity import PluginIdentity +from app.application.plugin.install import PluginInstallResult +from app.application.plugin.inventory import PLUGIN_V3_GENERATIONS +from app.application.plugin.lifecycle import PluginStartupLease, plugin_lifecycle +from app.application.plugin.source import ( + Candidate, + CandidateInventory, + PluginLocalCandidate, + PluginMarketCandidate, + PluginSelection, + get_effective_local_candidate, + list_effective_online_candidates, + select_plugin_candidate, +) + +InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]] +IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]] +CandidateCompatibility = Callable[[Candidate], tuple[bool, str]] + + +@dataclass(frozen=True, slots=True) +class PluginSourceInspection: + """前端与 Agent 选择来源所需的只读候选和当前身份快照。""" + + plugin_id: str + inventory_complete: bool + identity: PluginIdentity | None + selection: PluginSelection + online_candidates: tuple[PluginMarketCandidate, ...] + local_candidate: PluginLocalCandidate | None + + +class PluginInstallExecutor(Protocol): + """统一 Gateway 调用的可恢复安装执行端口。""" + + async def execute( + self, + *, + admission: PluginInstallAdmission, + release_version: str | None, + force: bool, + local_sync: bool = False, + ) -> PluginInstallResult: + """执行已通过来源准入的插件载荷事务。""" + + +class PluginInstallGateway: + """让全部插件载荷写入共享同一来源策略和事务执行器。""" + + def __init__( + self, + *, + inventory: InventoryProvider, + identity: IdentityReader, + candidate_compatibility: CandidateCompatibility, + executor: PluginInstallExecutor, + clock: Callable[[], datetime], + ) -> None: + """保存候选事实、身份读取、兼容校验、事务执行和时间端口。""" + self.__inventory = inventory + self.__identity = identity + self.__candidate_compatibility = candidate_compatibility + self.__executor = executor + self.__clock = clock + + async def install( + self, + *, + plugin_id: str, + repo_url: str | None, + package_version: str | None = None, + release_version: str | None = None, + force: bool = False, + explicit_source: bool = False, + source_change: bool = False, + expected_revision: int | None = None, + startup_token: PluginStartupLease | None = None, + local_sync: bool = False, + ) -> PluginInstallResult: + """读取冻结库存并执行一次不能绕过来源身份的插件写入。""" + try: + inventory = await self.__inventory(force) + async with plugin_lifecycle.hold(plugin_id, startup_token): + identity = await self.__identity(plugin_id) + admission = admit_plugin_install( + inventory, + request=PluginInstallAdmissionRequest( + plugin_id=plugin_id, + generations=_generation_order(package_version), + requested_repo_url=repo_url, + explicit_source=explicit_source, + source_change=source_change, + expected_revision=expected_revision, + ), + identity=identity, + now=self.__clock(), + ) + compatible, message = self.__candidate_compatibility( + admission.candidate + ) + if not compatible: + raise PluginSourceAdmissionError( + message or "插件候选与当前 MoviePilot 版本不兼容" + ) + return await self.__executor.execute( + admission=admission, + release_version=release_version, + force=force, + local_sync=local_sync, + ) + except (TypeError, ValueError, PluginSourceAdmissionError) as error: + return PluginInstallResult( + success=False, + message=str(error), + failure_stage="source_admission", + ) + + async def inspect_source( + self, + *, + plugin_id: str, + package_version: str | None = None, + force: bool = False, + ) -> PluginSourceInspection: + """读取与真实安装相同的库存和身份,返回脱敏来源选择快照。""" + inventory = await self.__inventory(force) + identity = await self.__identity(plugin_id) + generations = _generation_order(package_version) + selection = select_plugin_candidate( + inventory, + plugin_id=plugin_id, + generations=generations, + identity=identity, + ) + return PluginSourceInspection( + plugin_id=plugin_id, + inventory_complete=inventory.complete, + identity=identity, + selection=selection, + online_candidates=list_effective_online_candidates( + inventory, + plugin_id=plugin_id, + generations=generations, + ), + local_candidate=get_effective_local_candidate( + inventory, + plugin_id=plugin_id, + generations=generations, + ), + ) + + +_plugin_install_gateway: PluginInstallGateway | None = None + + +def configure_plugin_install_service(gateway: PluginInstallGateway) -> None: + """由启动组合根发布当前 lifespan 的唯一插件安装 Gateway。""" + global _plugin_install_gateway + _plugin_install_gateway = gateway + + +def get_plugin_install_service() -> PluginInstallGateway: + """返回已装配 Gateway;启动未完成时拒绝任何载荷写入。""" + if _plugin_install_gateway is None: + raise RuntimeError("插件安装服务尚未完成初始化") + return _plugin_install_gateway + + +def reset_plugin_install_service() -> None: + """清除当前 lifespan 的 Gateway,供停机和隔离测试使用。""" + global _plugin_install_gateway + _plugin_install_gateway = None + + +def _generation_order(package_version: str | None) -> tuple[str, ...]: + """把兼容入口的首选代际转换为来源选择优先序。""" + normalized = (package_version or "v3").strip().lower() + if normalized in {"", "v1"}: + return ("v1",) + if normalized == "v2": + return ("v2", "v1") + if normalized == "v3": + return PLUGIN_V3_GENERATIONS + raise ValueError("插件包代际必须为 v1、v2 或 v3") diff --git a/app/application/plugin/identity.py b/app/application/plugin/identity.py index 9f1a725d9..637665773 100644 --- a/app/application/plugin/identity.py +++ b/app/application/plugin/identity.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Callable from dataclasses import dataclass, replace from datetime import datetime from enum import StrEnum @@ -295,7 +296,7 @@ def plan_legacy_plugin_identity( trusted_key = None basis = PluginBindingBasis.LEGACY_UNBOUND bound_at = None - if market_availability is PluginMarketAvailability.AVAILABLE and official: + if official: trusted_type = TrustedPluginSourceType.OFFICIAL trusted_key = official[0].source_key basis = PluginBindingBasis.OFFICIAL_DEFAULT @@ -349,6 +350,21 @@ class PluginIdentityRepository(Protocol): """按 revision 条件暂存替换,并返回是否赢得竞争。""" +class PluginIdentityStore(Protocol): + """组合根注入的独立来源身份读取与存量迁移端口。""" + + def get(self, plugin_id: str) -> PluginIdentity | None: + """读取一个物理插件的来源身份。""" + + def compare_and_set( + self, + identity: PluginIdentity, + *, + expected_revision: int | None, + ) -> PluginIdentity: + """首次创建或按 revision 更新身份。""" + + class PluginIdentityUnitOfWork(Protocol): """来源身份条件写使用的事务端口。""" @@ -431,3 +447,275 @@ class WritePluginIdentityCommand: except Exception: self._unit_of_work.rollback() raise + + +class ChangePluginIdentitySourceCommand: + """以独立 CAS 合同提交一次明确的在线插件来源转换。""" + + def __init__( + self, + repository: PluginIdentityRepository, + unit_of_work: PluginIdentityUnitOfWork, + ) -> None: + """保存仓储与事务所有者。""" + self._repository = repository + self._unit_of_work = unit_of_work + + def execute( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """只允许已有身份按精确 revision 切换到不同在线来源。""" + return _execute_identity_transition( + self._repository, + self._unit_of_work, + identity, + expected_revision=expected_revision, + validate=_validate_identity_source_change, + ) + + +class BindOnlinePluginIdentityCommand: + """以独立 CAS 合同为未绑定身份建立在线可信来源。""" + + def __init__( + self, + repository: PluginIdentityRepository, + unit_of_work: PluginIdentityUnitOfWork, + ) -> None: + """保存仓储与事务所有者。""" + self._repository = repository + self._unit_of_work = unit_of_work + + def execute( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """只允许未绑定身份按精确 revision 首次绑定在线来源。""" + return _execute_identity_transition( + self._repository, + self._unit_of_work, + identity, + expected_revision=expected_revision, + validate=_validate_online_binding, + ) + + +class BindLocalPluginIdentityCommand: + """以独立 CAS 合同把存量未绑定身份转换为本地专属身份。""" + + def __init__( + self, + repository: PluginIdentityRepository, + unit_of_work: PluginIdentityUnitOfWork, + ) -> None: + """保存仓储与事务所有者。""" + self._repository = repository + self._unit_of_work = unit_of_work + + def execute( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """只允许 legacy_unbound 身份按精确 revision 绑定本地载荷。""" + return _execute_identity_transition( + self._repository, + self._unit_of_work, + identity, + expected_revision=expected_revision, + validate=_validate_local_binding, + ) + + +def _execute_identity_transition( + repository: PluginIdentityRepository, + unit_of_work: PluginIdentityUnitOfWork, + identity: PluginIdentity, + *, + expected_revision: int, + validate: Callable[[PluginIdentity, PluginIdentity], None], +) -> PluginIdentity: + """在一个数据库事务中校验并提交专用身份转换。""" + try: + current = _prepare_identity_transition( + repository, + identity, + expected_revision=expected_revision, + ) + validate(current, identity) + candidate = replace( + identity, + normalized_plugin_id=current.normalized_plugin_id, + revision=current.revision + 1, + created_at=current.created_at, + ) + _stage_identity_transition( + repository, + unit_of_work, + candidate, + expected_revision=expected_revision, + ) + return candidate + except Exception: + unit_of_work.rollback() + raise + + +def _prepare_identity_transition( + repository: PluginIdentityRepository, + identity: PluginIdentity, + *, + expected_revision: int, +) -> PluginIdentity: + """读取转换基线并保证目标使用同一物理插件和精确 revision。""" + if expected_revision < 1: + raise PluginIdentityConflictError("插件来源身份 expected_revision 必须从 1 开始") + normalized_plugin_id = normalize_physical_plugin_id(identity.plugin_id) + current = repository.get(normalized_plugin_id) + if current is None or current.revision != expected_revision: + raise PluginIdentityConflictError( + f"插件 {identity.plugin_id} 的来源身份 revision 已被其他任务更新" + ) + if ( + identity.plugin_id != current.plugin_id + or identity.normalized_plugin_id != current.normalized_plugin_id + ): + raise PluginIdentityConflictError( + "插件来源转换不能改变物理插件 ID" + ) + if identity.created_at != current.created_at: + raise PluginIdentityConflictError( + "插件来源转换必须保留身份创建时间" + ) + if identity.updated_at < current.updated_at: + raise PluginIdentityConflictError( + "插件身份更新时间不能早于已提交记录" + ) + return current + + +def _validate_identity_source_change( + current: PluginIdentity, + candidate: PluginIdentity, +) -> None: + """校验显式换源的来源、载荷和实际变化边界。""" + if candidate.binding_basis is not PluginBindingBasis.EXPLICIT_SOURCE_CHANGE: + raise PluginIdentityConflictError( + "显式换源目标必须使用 explicit_source_change 依据" + ) + if candidate.trusted_source_type is TrustedPluginSourceType.UNKNOWN: + raise PluginIdentityConflictError("显式换源目标必须是在线可信来源") + if candidate.payload_source_type not in { + PluginPayloadSourceType.OFFICIAL, + PluginPayloadSourceType.THIRD_PARTY, + }: + raise PluginIdentityConflictError("显式换源目标必须携带在线载荷") + if ( + candidate.trusted_source_type.value != candidate.payload_source_type.value + or candidate.trusted_source_key != candidate.payload_source_key + ): + raise PluginIdentityConflictError( + "显式换源目标的 trusted 与 payload 来源必须一致" + ) + if ( + current.trusted_source_type is candidate.trusted_source_type + and current.trusted_source_key == candidate.trusted_source_key + ): + raise PluginIdentityConflictError("显式换源的实际来源必须变化") + + +def _validate_online_binding( + current: PluginIdentity, + candidate: PluginIdentity, +) -> None: + """校验未绑定身份首次建立在线可信来源的转换边界。""" + if ( + current.trusted_source_type is not TrustedPluginSourceType.UNKNOWN + or current.binding_basis not in { + PluginBindingBasis.LEGACY_UNBOUND, + PluginBindingBasis.LOCAL_ONLY, + } + ): + raise PluginIdentityConflictError( + "在线绑定只允许当前未绑定的存量或本地身份" + ) + if candidate.binding_basis not in { + PluginBindingBasis.OFFICIAL_DEFAULT, + PluginBindingBasis.TOFU, + PluginBindingBasis.EXPLICIT_INSTALL, + }: + raise PluginIdentityConflictError( + "在线绑定目标必须说明官方、TOFU 或显式安装依据" + ) + if candidate.trusted_source_type is TrustedPluginSourceType.UNKNOWN: + raise PluginIdentityConflictError("在线绑定目标必须携带可信来源") + if candidate.payload_source_type is PluginPayloadSourceType.UNKNOWN: + if ( + current.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND + or candidate.binding_basis not in { + PluginBindingBasis.OFFICIAL_DEFAULT, + PluginBindingBasis.TOFU, + } + ): + raise PluginIdentityConflictError( + "仅存量未知来源身份可在不声明载荷来源时建立默认在线绑定" + ) + return + if candidate.payload_source_type not in { + PluginPayloadSourceType.OFFICIAL, + PluginPayloadSourceType.THIRD_PARTY, + }: + raise PluginIdentityConflictError("在线绑定目标必须携带在线载荷") + if ( + candidate.trusted_source_type.value != candidate.payload_source_type.value + or candidate.trusted_source_key != candidate.payload_source_key + ): + raise PluginIdentityConflictError( + "在线绑定目标的 trusted 与 payload 来源必须一致" + ) + + +def _validate_local_binding( + current: PluginIdentity, + candidate: PluginIdentity, +) -> None: + """校验存量未绑定身份到本地身份的唯一转换方向。""" + if ( + current.trusted_source_type is not TrustedPluginSourceType.UNKNOWN + or current.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND + ): + raise PluginIdentityConflictError( + "本地绑定只允许当前 unknown + legacy_unbound 身份" + ) + if ( + candidate.trusted_source_type is not TrustedPluginSourceType.UNKNOWN + or candidate.binding_basis is not PluginBindingBasis.LOCAL_ONLY + or candidate.payload_source_type is not PluginPayloadSourceType.LOCAL + ): + raise PluginIdentityConflictError( + "本地绑定目标必须是 unknown + local_only 且携带本地载荷" + ) + + +def _stage_identity_transition( + repository: PluginIdentityRepository, + unit_of_work: PluginIdentityUnitOfWork, + candidate: PluginIdentity, + *, + expected_revision: int, +) -> None: + """按数据库 revision 条件暂存转换并提交事务。""" + if not repository.stage_replace( + candidate, + expected_revision=expected_revision, + ): + raise PluginIdentityConflictError( + f"插件 {candidate.plugin_id} 的来源身份 revision 已被其他任务更新" + ) + unit_of_work.commit() diff --git a/app/application/plugin/identity_migration.py b/app/application/plugin/identity_migration.py new file mode 100644 index 000000000..2ab470c52 --- /dev/null +++ b/app/application/plugin/identity_migration.py @@ -0,0 +1,205 @@ +"""存量插件来源身份的一次性启动迁移。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace +from datetime import datetime +from typing import Protocol + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginIdentityConflictError, + PluginMarketAvailability, + PluginSourceCandidate, + TrustedPluginSourceType, + normalize_physical_plugin_id, + plan_legacy_plugin_identity, +) +from app.application.plugin.source import CandidateInventory +from app.runtime.log import logger + +InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]] +InstalledPluginsReader = Callable[[], list[str]] +VirtualInstancePredicate = Callable[[str], bool] + + +class PluginIdentityMigrationPersistence(Protocol): + """存量来源迁移所需的最小异步持久化端口。""" + + async def get_identity(self, plugin_id: str) -> PluginIdentity | None: + """读取一个物理插件的当前身份。""" + + async def migrate_identity( + self, + identity: PluginIdentity, + *, + expected_revision: int | None, + ) -> PluginIdentity: + """创建尚不存在的存量身份。""" + + async def bind_online_identity( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """把 legacy_unbound 身份绑定到已确认的在线来源。""" + + +@dataclass(frozen=True, slots=True) +class PluginIdentityMigrationResult: + """一次迁移批次创建、绑定和跳过的物理插件数量。""" + + created: int = 0 + bound: int = 0 + unbound: int = 0 + skipped: int = 0 + + +class PluginIdentityMigrationService: + """在任何自动更新前为已安装物理插件建立最小来源身份。""" + + def __init__( + self, + *, + persistence: PluginIdentityMigrationPersistence, + inventory: InventoryProvider, + installed_plugins: InstalledPluginsReader, + is_virtual_instance: VirtualInstancePredicate, + clock: Callable[[], datetime], + ) -> None: + """保存库存、安装清单、虚拟实例判定和 CAS 端口。""" + self.__persistence = persistence + self.__inventory = inventory + self.__installed_plugins = installed_plugins + self.__is_virtual_instance = is_virtual_instance + self.__clock = clock + + async def migrate(self) -> PluginIdentityMigrationResult: + """幂等迁移全部存量身份;数据库异常会阻止后续自动更新。""" + inventory = await self.__inventory(False) + created = 0 + bound = 0 + unbound = 0 + skipped = 0 + seen: set[str] = set() + + for plugin_id in self.__installed_plugins() or []: + try: + normalized_id = normalize_physical_plugin_id(plugin_id) + except ValueError as error: + logger.warning("跳过插件 %s 的存量来源迁移:%s", plugin_id, error) + skipped += 1 + continue + + if normalized_id in seen or self.__is_virtual_instance(plugin_id): + skipped += 1 + continue + seen.add(normalized_id) + candidates = inventory.candidates_for(plugin_id) + planned = plan_legacy_plugin_identity( + plugin_id=plugin_id, + market_availability=( + PluginMarketAvailability.AVAILABLE + if inventory.can_use_for_tofu + else PluginMarketAvailability.UNAVAILABLE + ), + online_candidates=tuple( + PluginSourceCandidate( + source_type=candidate.source_type, + source_key=candidate.source_key, + ) + for candidate in candidates + ), + is_virtual_instance=False, + now=self.__clock(), + ) + existing = await self.__persistence.get_identity(normalized_id) + + if planned is None: + skipped += 1 + continue + if existing is None: + try: + migrated = await self.__persistence.migrate_identity( + planned, + expected_revision=None, + ) + except PluginIdentityConflictError: + if await self.__persistence.get_identity(plugin_id) is None: + raise + skipped += 1 + continue + created += 1 + if migrated.trusted_source_type is TrustedPluginSourceType.UNKNOWN: + unbound += 1 + else: + bound += 1 + continue + if ( + existing.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND + or planned.trusted_source_type is TrustedPluginSourceType.UNKNOWN + ): + skipped += 1 + continue + + target = replace( + planned, + plugin_id=existing.plugin_id, + normalized_plugin_id=existing.normalized_plugin_id, + revision=existing.revision + 1, + created_at=existing.created_at, + updated_at=self.__clock(), + ) + try: + await self.__persistence.bind_online_identity( + target, + expected_revision=existing.revision, + ) + except PluginIdentityConflictError: + current = await self.__persistence.get_identity(plugin_id) + if current is None or current.revision == existing.revision: + raise + skipped += 1 + continue + bound += 1 + + result = PluginIdentityMigrationResult( + created=created, + bound=bound, + unbound=unbound, + skipped=skipped, + ) + logger.info( + "插件来源身份迁移完成:创建=%s,已绑定=%s,未绑定=%s,跳过=%s", + result.created, + result.bound, + result.unbound, + result.skipped, + ) + return result + + +_IDENTITY_MIGRATION_SERVICE: list[PluginIdentityMigrationService] = [] + + +def configure_plugin_identity_migration( + service: PluginIdentityMigrationService, +) -> None: + """由组合根登记当前 lifespan 的存量身份迁移服务。""" + _IDENTITY_MIGRATION_SERVICE.clear() + _IDENTITY_MIGRATION_SERVICE.append(service) + + +def get_plugin_identity_migration() -> PluginIdentityMigrationService: + """返回已装配迁移服务;缺失时拒绝绕过来源迁移。""" + if not _IDENTITY_MIGRATION_SERVICE: + raise RuntimeError("插件来源身份迁移服务尚未完成初始化") + return _IDENTITY_MIGRATION_SERVICE[0] + + +def reset_plugin_identity_migration() -> None: + """清除当前 lifespan 的迁移服务,供测试和停机复位。""" + _IDENTITY_MIGRATION_SERVICE.clear() diff --git a/app/application/plugin/install.py b/app/application/plugin/install.py index 268d84c94..4f7d3206e 100644 --- a/app/application/plugin/install.py +++ b/app/application/plugin/install.py @@ -1,48 +1,108 @@ -"""插件安装应用用例。""" +"""插件载荷安装、数据库提交和运行态切换的统一应用用例。""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Any, ContextManager, Optional +from datetime import datetime +from typing import Any, ContextManager, Protocol, TypeVar -from app.schemas.exception import PersistenceUnavailableError -from app.application.plugin.lifecycle import plugin_lifecycle +from app.application.plugin.admission import PluginInstallAdmission +from app.application.plugin.identity import PluginIdentity, PluginPayloadSourceType +from app.application.plugin.source import PluginLocalCandidate +from app.application.plugin.transaction import ( + PluginInstallationConflictError, + PluginInstallationPhase, + PluginInstallationRecord, + PluginPersistenceService, +) from app.runtime.execution import await_task_to_terminal from app.runtime.log import logger -from app.schemas.exception import PluginMutationRejectedError - +from app.schemas.exception import ( + PersistenceUnavailableError, + PluginMutationRejectedError, +) InstalledPluginsReader = Callable[[], list[str]] -InstalledPluginsWriter = Callable[[list[str]], Awaitable[object]] PluginIdsProvider = Callable[[], list[str]] -CompatibilityChecker = Callable[[str, str], Awaitable[Optional[str]]] -PackageInstaller = Callable[ - [str, str, Optional[str], bool], - Awaitable[tuple[bool, str]], -] -PackageCheckpointer = Callable[[str], Awaitable[Any]] -PackageCheckpointAction = Callable[[Any], Awaitable[object]] -InstallReporter = Callable[[str, Optional[str]], Awaitable[object]] +InstallReporter = Callable[[str, str | None], Awaitable[object]] PluginReloader = Callable[[str], Awaitable[object]] PluginRegistrationRefresher = Callable[[str], Awaitable[object]] PluginMutationAdmission = Callable[[str], ContextManager[None]] PluginPackageWriteGuard = Callable[[str], ContextManager[None]] +T = TypeVar("T") + + +class PluginPackageCheckpoint(Protocol): + """安装 journal 构造和崩溃恢复所需的最小文件快照事实。""" + + plugin_existed: bool + persistent_backup_existed: bool + + +class PluginPackageTransactionPort(Protocol): + """插件安装用例可见的唯一文件与持久备份事务端口。""" + + async def async_checkpoint( + self, + plugin_id: str, + transaction_id: str | None = None, + ) -> PluginPackageCheckpoint: + """创建运行目录恢复快照。""" + + async def async_install( + self, + plugin_id: str, + repo_url: str, + package_version: str | None = None, + release_version: str | None = None, + force_install: bool = False, + ) -> tuple[bool, str]: + """执行已经通过来源准入的原始包安装。""" + + async def async_restore(self, checkpoint: PluginPackageCheckpoint) -> None: + """恢复数据库提交前的运行目录和持久备份。""" + + async def async_cleanup(self, checkpoint: PluginPackageCheckpoint) -> None: + """清理已不再被 journal 引用的恢复材料。""" + + async def async_stage_persistent_backup( + self, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """准备新的容器持久恢复备份。""" + + async def async_activate_persistent_backup( + self, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """激活新备份并保留旧备份用于提交前补偿。""" + + async def async_finalize_persistent_backup( + self, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """数据库提交后删除旧持久备份。""" + + async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None: + """清理已提交事务的运行目录快照。""" + + async def async_payload_receipt(self, plugin_id: str) -> str: + """读取当前运行目录的稳定载荷收据。""" + @dataclass(frozen=True, slots=True) class PluginInstallRollback: - """描述失败安装中各类可补偿副作用的恢复结果。""" + """描述提交前失败中各类可补偿副作用的恢复结果。""" file_attempted: bool = False file_restored: bool = False - installed_list_attempted: bool = False - installed_list_restored: bool = False runtime_attempted: bool = False runtime_restored: bool = False registrations_attempted: bool = False registrations_restored: bool = False - dependency_supported: bool = False + journal_deleted: bool = False errors: tuple[str, ...] = () @@ -59,264 +119,345 @@ class PluginInstallResult: registrations_refreshed: bool = False reported: bool = False report_error: str = "" - failure_stage: Optional[str] = None + failure_stage: str | None = None checkpoint_cleanup_error: str = "" rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback) -@dataclass +@dataclass(slots=True) class _InstallState: - """记录取消补偿所需的事务阶段。""" + """记录取消补偿和数据库提交边界所需的事务状态。""" + transaction_id: str checkpoint: Any = None + journal_created: bool = False + journal_unknown: bool = False + target_identity: PluginIdentity | None = None stage: str = "package_checkpoint" package_installed: bool = False - installed_list_touched: bool = False - installed_list_persisted: bool = False runtime_touched: bool = False registrations_touched: bool = False - refresh_compensated: bool = False committed: bool = False - original_plugins: list[str] = field(default_factory=list) + commit_unknown: bool = False class PluginInstallCommand: - """协调插件检查、包事务、持久化、运行态刷新和安装上报。""" + """以一个 Gateway 后端协调来源、文件、数据库与运行态提交。""" def __init__( self, *, + persistence: PluginPersistenceService, installed_plugins_reader: InstalledPluginsReader, - installed_plugins_writer: InstalledPluginsWriter, plugin_ids_provider: PluginIdsProvider, - compatibility_checker: CompatibilityChecker, - package_installer: PackageInstaller, - package_checkpointer: PackageCheckpointer, - package_committer: PackageCheckpointAction, - package_rollback: PackageCheckpointAction, + packages: PluginPackageTransactionPort, install_reporter: InstallReporter, - plugin_reloader: PluginReloader, + target_reloader: PluginReloader, + rollback_reloader: PluginReloader, registration_refresher: PluginRegistrationRefresher, mutation: PluginMutationAdmission, package_write_guard: PluginPackageWriteGuard, + clock: Callable[[], datetime], + transaction_id_factory: Callable[[], str], ) -> None: - """保存安装用例所需端口,不绑定数据库、网络或运行时实现。""" - self._installed_plugins_reader = installed_plugins_reader - self._installed_plugins_writer = installed_plugins_writer - self._plugin_ids_provider = plugin_ids_provider - self._compatibility_checker = compatibility_checker - self._package_installer = package_installer - self._package_checkpointer = package_checkpointer - self._package_committer = package_committer - self._package_rollback = package_rollback - self._install_reporter = install_reporter - self._plugin_reloader = plugin_reloader - self._registration_refresher = registration_refresher - self._mutation = mutation - self._package_write_guard = package_write_guard + """保存单一安装事务所需的窄端口。""" + self.__persistence = persistence + self.__installed_plugins_reader = installed_plugins_reader + self.__plugin_ids_provider = plugin_ids_provider + self.__packages = packages + self.__install_reporter = install_reporter + self.__target_reloader = target_reloader + self.__rollback_reloader = rollback_reloader + self.__registration_refresher = registration_refresher + self.__mutation = mutation + self.__package_write_guard = package_write_guard + self.__clock = clock + self.__transaction_id_factory = transaction_id_factory async def execute( self, *, - plugin_id: str, - repo_url: Optional[str], - release_version: Optional[str] = None, - force: bool = False, + admission: PluginInstallAdmission, + release_version: str | None, + force: bool, + local_sync: bool = False, ) -> PluginInstallResult: - """串行执行同一插件的完整安装生命周期,并保证取消后的补偿。""" - state = _InstallState() - async with plugin_lifecycle.hold(plugin_id): - try: - with self._mutation(f"安装插件 {plugin_id}"): - with self._package_write_guard(plugin_id): - try: - return await self._execute_locked( - plugin_id=plugin_id, - repo_url=repo_url, - release_version=release_version, - force=force, - state=state, - ) - except asyncio.CancelledError: - await self._rollback_cancelled( - plugin_id=plugin_id, - original_plugins=state.original_plugins, - state=state, - ) - raise - except PluginMutationRejectedError as error: - return PluginInstallResult( - success=False, - message=str(error), - failure_stage="admission", - ) + """执行已准入事务;共享 Python 依赖不属于文件与数据库补偿边界。""" + plugin_id = admission.candidate.plugin_id + state = _InstallState(transaction_id=self.__transaction_id_factory()) + try: + with self.__mutation(f"安装插件 {plugin_id}"): + with self.__package_write_guard(plugin_id): + try: + return await self.__execute_locked( + admission=admission, + release_version=release_version, + force=force, + local_sync=local_sync, + state=state, + ) + except asyncio.CancelledError: + await self.__rollback_cancelled( + plugin_id=plugin_id, + state=state, + ) + raise + except PluginMutationRejectedError as error: + return PluginInstallResult( + success=False, + message=str(error), + failure_stage="admission", + ) - async def _execute_locked( + async def __execute_locked( self, *, - plugin_id: str, - repo_url: Optional[str], - release_version: Optional[str], + admission: PluginInstallAdmission, + release_version: str | None, force: bool, + local_sync: bool, state: _InstallState, ) -> PluginInstallResult: - """执行插件安装,并在关键阶段失败时恢复可补偿状态。""" - installed_plugins = list(self._installed_plugins_reader() or []) - state.original_plugins = installed_plugins - refreshed_only = not force and plugin_id in self._plugin_ids_provider() - if refreshed_only: - return await self._refresh_existing( - plugin_id=plugin_id, - repo_url=repo_url, - state=state, + """执行文件准备、运行态验证和数据库最终提交。""" + candidate = admission.candidate + plugin_id = candidate.plugin_id + membership_before = any( + item.lower() == plugin_id.lower() + for item in (self.__installed_plugins_reader() or []) + ) + if ( + not force + and not local_sync + and release_version is None + and any( + item.lower() == plugin_id.lower() + for item in self.__plugin_ids_provider() ) - if not repo_url: - return PluginInstallResult( - success=False, - message="没有传入仓库地址,无法正确安装插件,请检查配置", - failure_stage="validation", + and await self.__payload_matches(admission) + ): + return await self.__refresh_existing( + plugin_id, + candidate.repo_url, + report=not isinstance(candidate, PluginLocalCandidate), ) - checkpoint_task = asyncio.create_task(self._package_checkpointer(plugin_id)) try: - checkpoint = await asyncio.shield(checkpoint_task) - state.checkpoint = checkpoint - except asyncio.CancelledError: - try: - state.checkpoint = await await_task_to_terminal(checkpoint_task) - except BaseException: - pass - raise - except Exception as err: + async def create_checkpoint() -> None: + """在取消传播前保存已经创建完成的恢复材料引用。""" + state.checkpoint = await self.__packages.async_checkpoint( + plugin_id, + state.transaction_id, + ) + + await self.__await_side_effect(create_checkpoint()) + except Exception as error: return PluginInstallResult( success=False, - message=f"创建插件安装快照失败:{err}", + message=f"创建插件安装快照失败:{error}", failure_stage="package_checkpoint", ) + now = self.__clock() + record = PluginInstallationRecord( + transaction_id=state.transaction_id, + plugin_id=plugin_id, + phase=PluginInstallationPhase.PREPARED, + membership_before=membership_before, + membership_target=None, + identity_before_revision=admission.expected_revision, + identity_target_revision=None, + package_existed=bool(state.checkpoint.plugin_existed), + persistent_backup_existed=bool( + state.checkpoint.persistent_backup_existed + ), + created_at=now, + updated_at=now, + ) + try: + async def create_journal() -> None: + """在取消传播前记录 PREPARED 已持久化。""" + await self.__persistence.create_installation(record) + state.journal_created = True + + await self.__await_side_effect(create_journal()) + except asyncio.CancelledError: + if not state.journal_created: + await self.__resolve_journal_created(state) + raise + except PluginInstallationConflictError as error: + rollback = await self.__rollback_without_journal(state.checkpoint) + return PluginInstallResult( + success=False, + message=str(error), + failure_stage="journal_prepare_conflict", + rollback=rollback, + ) + except Exception as error: + journal_created = await self.__resolve_journal_created(state) + if journal_created is None: + return PluginInstallResult( + success=False, + message=( + "插件安装事务创建结果暂时无法确认,已保留恢复材料," + "重启后将自动核对" + ), + failure_stage="journal_prepare_unknown", + ) + rollback = ( + await self.__fail_prepared(plugin_id=plugin_id, state=state) + if journal_created + else await self.__rollback_without_journal(state.checkpoint) + ) + if isinstance(error, PersistenceUnavailableError): + raise + return PluginInstallResult( + success=False, + message=f"创建插件安装事务失败:{error}", + failure_stage="journal_prepare", + rollback=rollback, + ) + state.stage = "package_install" try: - package_installed, message = await self._package_installer( - plugin_id, - repo_url, - release_version, - force, + package_installed, message = await self.__await_side_effect( + self.__packages.async_install( + plugin_id=plugin_id, + repo_url=candidate.repo_url, + package_version=candidate.package_generation, + release_version=release_version, + force_install=True, + ) ) state.package_installed = package_installed - except Exception as err: - result = await self._failure( - plugin_id=plugin_id, - original_plugins=installed_plugins, - checkpoint=checkpoint, - stage="package_install", - message=str(err), - package_installed=False, - ) - if isinstance(err, PersistenceUnavailableError): + except Exception as error: + if isinstance(error, PersistenceUnavailableError): + await self.__fail_prepared(plugin_id=plugin_id, state=state) raise - return result - if not package_installed: - return await self._failure( + return await self.__failure_result( plugin_id=plugin_id, - original_plugins=installed_plugins, - checkpoint=checkpoint, + state=state, + stage="package_install", + message=str(error), + ) + if not package_installed: + return await self.__failure_result( + plugin_id=plugin_id, + state=state, stage="package_install", message=message, - package_installed=False, ) - installed_list_persisted = False - if plugin_id not in installed_plugins: - updated_plugins = [*installed_plugins, plugin_id] - try: - # 写入方可能在返回前已经提交;取消时按已触碰处理,恢复原清单是幂等的。 - state.installed_list_touched = True - await self._installed_plugins_writer(updated_plugins) - installed_list_persisted = True - state.installed_list_persisted = True - except Exception as err: - result = await self._failure( - plugin_id=plugin_id, - original_plugins=installed_plugins, - checkpoint=checkpoint, - stage="installed_list_persistence", - message=str(err), - package_installed=True, - installed_list_persisted=state.installed_list_touched, + try: + state.stage = "payload_receipt" + receipt = await self.__await_side_effect( + self.__packages.async_payload_receipt(plugin_id) + ) + state.target_identity = admission.build_identity( + payload_receipt=receipt, + applied_at=self.__clock(), + declared_version=release_version, + ) + await self.__await_side_effect( + self.__persistence.set_installation_target( + state.transaction_id, + membership_target=True, + identity_target=state.target_identity, ) - if isinstance(err, PersistenceUnavailableError): + ) + + state.stage = "persistent_backup_stage" + await self.__await_side_effect( + self.__packages.async_stage_persistent_backup(state.checkpoint) + ) + state.stage = "persistent_backup_activate" + await self.__await_side_effect( + self.__packages.async_activate_persistent_backup(state.checkpoint) + ) + + state.stage = "runtime_reload" + state.runtime_touched = True + await self.__await_side_effect(self.__target_reloader(plugin_id)) + state.stage = "registration_refresh" + state.registrations_touched = True + await self.__await_side_effect( + self.__registration_refresher(plugin_id) + ) + except asyncio.CancelledError: + raise + except Exception as error: + if isinstance(error, PersistenceUnavailableError): + await self.__fail_prepared(plugin_id=plugin_id, state=state) + raise + return await self.__failure_result( + plugin_id=plugin_id, + state=state, + stage=state.stage, + message=str(error), + ) + + state.stage = "database_commit" + try: + async def commit_database() -> None: + """仅在数据库调用明确返回后标记提交已确认。""" + await self.__persistence.commit_installation( + state.transaction_id, + identity_target=state.target_identity, + ) + state.committed = True + + await self.__await_side_effect(commit_database()) + except asyncio.CancelledError: + await self.__resolve_commit_outcome(state) + raise + except Exception as error: + outcome = await self.__resolve_commit_outcome(state) + if outcome is None: + return PluginInstallResult( + success=False, + message=( + "插件数据库提交结果暂时无法确认,已保留安装事务," + "重启后将自动恢复" + ), + package_installed=True, + runtime_reloaded=True, + registrations_refreshed=True, + failure_stage="database_commit_unknown", + ) + if not outcome: + if isinstance(error, PersistenceUnavailableError): + await self.__fail_prepared(plugin_id=plugin_id, state=state) raise - return result - - state.stage = "runtime_reload" - state.runtime_touched = True - try: - await self._plugin_reloader(plugin_id) - except Exception as err: - result = await self._failure( - plugin_id=plugin_id, - original_plugins=installed_plugins, - checkpoint=checkpoint, - stage="runtime_reload", - message=str(err), - package_installed=True, - installed_list_persisted=installed_list_persisted, - runtime_touched=True, + return await self.__failure_result( + plugin_id=plugin_id, + state=state, + stage="database_commit", + message=str(error), + ) + logger.warning( + "插件安装事务 %s 提交返回异常,但数据库已确认 COMMITTED:%s", + state.transaction_id, + error, ) - if isinstance(err, PersistenceUnavailableError): - raise - return result - - state.stage = "registration_refresh" - state.registrations_touched = True - try: - await self._registration_refresher(plugin_id) - except Exception as err: - result = await self._failure( - plugin_id=plugin_id, - original_plugins=installed_plugins, - checkpoint=checkpoint, - stage="registration_refresh", - message=str(err), - package_installed=True, - installed_list_persisted=installed_list_persisted, - runtime_touched=True, - registrations_touched=True, - ) - if isinstance(err, PersistenceUnavailableError): - raise - return result - - checkpoint_cleanup_error = "" - state.stage = "checkpoint_commit" - # 运行态和注册已完成,后续只清理临时快照,不再把取消当作未提交安装回滚。 - state.committed = True - try: - await self._package_committer(checkpoint) - except Exception as err: - checkpoint_cleanup_error = str(err) - - reported = False - report_error = "" - state.stage = "report" - try: - report_result = await self._install_reporter(plugin_id, repo_url) - reported = report_result is not False - if not reported: - report_error = "安装上报未确认" - except Exception as err: - report_error = str(err) + checkpoint_cleanup_error = await self.__finish_committed(state) + reported, report_error = await self.__report( + plugin_id, + candidate.repo_url, + enabled=( + not local_sync + and not isinstance(candidate, PluginLocalCandidate) + ), + ) result_message = message or "插件安装成功" if checkpoint_cleanup_error: - result_message = f"{result_message};临时安装快照清理失败" + result_message = f"{result_message};安装事务待下次启动继续清理" if report_error: result_message = f"{result_message};安装上报失败,不影响本地安装" return PluginInstallResult( success=True, message=result_message, package_installed=True, - installed_list_persisted=installed_list_persisted, + installed_list_persisted=True, runtime_reloaded=True, registrations_refreshed=True, reported=reported, @@ -324,112 +465,65 @@ class PluginInstallCommand: checkpoint_cleanup_error=checkpoint_cleanup_error, ) - async def _rollback_cancelled( - self, - *, - plugin_id: str, - original_plugins: list[str], - state: _InstallState, - ) -> None: - """在保留取消语义的同时完成文件、清单和运行态补偿。""" - if state.committed: - logger.warning( - f"插件 {plugin_id} 在安装提交后被取消,Python 依赖环境可能已经改变" - ) - return - if state.refresh_compensated: - return - if state.checkpoint is None: - logger.warning( - f"插件 {plugin_id} 在创建安装快照前被取消,无法执行文件补偿" - ) - return - - rollback_task = asyncio.create_task( - self._failure( - plugin_id=plugin_id, - original_plugins=original_plugins, - checkpoint=state.checkpoint, - stage=state.stage, - message="插件安装已取消", - package_installed=state.package_installed, - installed_list_persisted=state.installed_list_touched, - runtime_touched=state.runtime_touched, - registrations_touched=state.registrations_touched, - ) + async def __payload_matches(self, admission: PluginInstallAdmission) -> bool: + """确认身份元数据与当前运行目录收据都描述同一载荷。""" + identity = admission.identity_before + candidate = admission.candidate + if identity is None or identity.payload_source_type is PluginPayloadSourceType.UNKNOWN: + return False + if ( + identity.declared_version != candidate.plugin_version + or identity.package_generation != candidate.package_generation + or identity.payload_source_type is not candidate.payload_source_type + ): + return False + source_matches = ( + identity.payload_source_key is None + if isinstance(candidate, PluginLocalCandidate) + else identity.payload_source_key == candidate.source_key ) + if not source_matches or identity.payload_receipt is None: + return False try: - result = await await_task_to_terminal(rollback_task) - except BaseException as err: - logger.error(f"插件 {plugin_id} 取消后的补偿失败:{err}") - return - if result.rollback.errors: - logger.error( - f"插件 {plugin_id} 取消后的补偿存在错误:{';'.join(result.rollback.errors)}" + current_receipt = await self.__await_side_effect( + self.__packages.async_payload_receipt(candidate.plugin_id) ) - logger.warning( - f"插件 {plugin_id} 安装已取消,插件文件已尝试恢复,Python 依赖环境可能已经改变" - ) + except Exception as error: # noqa: BLE001 - 无法证明相同就走完整安装 + logger.warning( + "读取插件 %s 当前载荷收据失败,将重新安装:%s", + candidate.plugin_id, + error, + ) + return False + return current_receipt == identity.payload_receipt - async def _refresh_existing( + async def __refresh_existing( self, - *, plugin_id: str, - repo_url: Optional[str], - state: _InstallState, + repo_url: str | None, + *, + report: bool, ) -> PluginInstallResult: - """刷新已存在插件,不触碰包文件和已安装列表。""" - if repo_url: - compatible_message = await self._compatibility_checker( - plugin_id, - repo_url, - ) - if compatible_message: - return PluginInstallResult( - success=False, - message=compatible_message, - refreshed_only=True, - failure_stage="compatibility", - ) + """刷新已经提交且载荷事实未变化的插件运行态。""" failure_stage = "runtime_reload" try: - await self._plugin_reloader(plugin_id) + await self.__await_side_effect(self.__target_reloader(plugin_id)) failure_stage = "registration_refresh" - await self._registration_refresher(plugin_id) - except asyncio.CancelledError: - cleanup_task = asyncio.create_task( - self._restore_refreshed_runtime(plugin_id) + await self.__await_side_effect( + self.__registration_refresher(plugin_id) ) - rollback = await await_task_to_terminal(cleanup_task) - state.refresh_compensated = True - if rollback.errors: - logger.error( - f"插件 {plugin_id} 取消刷新后的运行态补偿存在错误:" - f"{';'.join(rollback.errors)}" - ) - raise - except Exception as err: - rollback = await self._restore_refreshed_runtime(plugin_id) - result = PluginInstallResult( + except Exception as error: + return PluginInstallResult( success=False, - message=f"刷新插件运行态失败:{err}", + message=f"刷新插件运行态失败:{error}", refreshed_only=True, failure_stage=failure_stage, - rollback=rollback, ) - if isinstance(err, PersistenceUnavailableError): - raise - return result - - reported = False - report_error = "" - try: - report_result = await self._install_reporter(plugin_id, repo_url) - reported = report_result is not False - if not reported: - report_error = "安装上报未确认" - except Exception as err: - report_error = str(err) + reported, report_error = await self.__report( + plugin_id, + repo_url, + enabled=report, + ) return PluginInstallResult( success=True, message=( @@ -444,95 +538,269 @@ class PluginInstallCommand: report_error=report_error, ) - async def _restore_refreshed_runtime( - self, - plugin_id: str, - ) -> PluginInstallRollback: - """重新加载插件并刷新注册,使中断的运行态切换恢复到完整状态。""" - errors = [] - runtime_restored = False - registrations_restored = False + async def __finish_committed(self, state: _InstallState) -> str: + """幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。""" try: - await self._plugin_reloader(plugin_id) - runtime_restored = True - except Exception as err: - errors.append(f"运行态恢复失败:{err}") - if runtime_restored: - try: - await self._registration_refresher(plugin_id) - registrations_restored = True - except Exception as err: - errors.append(f"路由和服务注册恢复失败:{err}") - return PluginInstallRollback( - runtime_attempted=True, - runtime_restored=runtime_restored, - registrations_attempted=True, - registrations_restored=registrations_restored, - errors=tuple(errors), - ) + await self.__await_side_effect( + self.__packages.async_finalize_persistent_backup(state.checkpoint) + ) + await self.__await_side_effect( + self.__packages.async_commit(state.checkpoint) + ) + await self.__await_side_effect( + self.__persistence.delete_installation( + state.transaction_id, + expected_phase=PluginInstallationPhase.COMMITTED, + ) + ) + except Exception as error: # noqa: BLE001 - 已提交终态不能反向回滚 + logger.warning( + "插件安装事务 %s 已提交但清理未完成:%s", + state.transaction_id, + error, + ) + return str(error) + return "" - async def _failure( + async def __resolve_journal_created( + self, + state: _InstallState, + ) -> bool | None: + """PREPARED 创建确认异常时核对事务是否已经持久化。""" + if state.journal_created: + return True + task = asyncio.create_task( + self.__persistence.get_installation(state.transaction_id) + ) + try: + record = await await_task_to_terminal(task) + except BaseException as error: + state.journal_unknown = True + logger.error( + "插件安装事务 %s 无法确认 PREPARED 创建结果:%s", + state.transaction_id, + error, + ) + return None + if record is None: + state.journal_unknown = False + return False + if record.phase is not PluginInstallationPhase.PREPARED: + state.journal_unknown = True + logger.error( + "插件安装事务 %s 在 PREPARED 创建确认时阶段异常:%s", + state.transaction_id, + record.phase.value, + ) + return None + state.journal_created = True + state.journal_unknown = False + return True + + async def __resolve_commit_outcome( + self, + state: _InstallState, + ) -> bool | None: + """提交确认异常时读取 journal,拒绝猜测数据库最终状态。""" + if state.committed: + return True + task = asyncio.create_task( + self.__persistence.get_installation(state.transaction_id) + ) + try: + record = await await_task_to_terminal(task) + except BaseException as error: # 数据库仍不可用时只能留待启动恢复 + state.commit_unknown = True + logger.error( + "插件安装事务 %s 无法确认数据库提交结果:%s", + state.transaction_id, + error, + ) + return None + if record is None: + state.commit_unknown = True + logger.error( + "插件安装事务 %s 在提交确认时已不存在", + state.transaction_id, + ) + return None + if record.phase is PluginInstallationPhase.COMMITTED: + state.committed = True + state.commit_unknown = False + return True + state.commit_unknown = False + return False + + async def __failure_result( self, *, plugin_id: str, - original_plugins: list[str], - checkpoint: Any, + state: _InstallState, stage: str, message: str, - package_installed: bool, - installed_list_persisted: bool = False, - runtime_touched: bool = False, - registrations_touched: bool = False, ) -> PluginInstallResult: - """按持久化、文件、运行态顺序补偿失败安装并记录结果。""" - errors = [] - installed_list_restored = False - if installed_list_persisted: - try: - await self._installed_plugins_writer(list(original_plugins)) - installed_list_restored = True - except Exception as err: - errors.append(f"已安装列表恢复失败:{err}") - - file_restored = False - try: - await self._package_rollback(checkpoint) - file_restored = True - except Exception as err: - errors.append(f"插件文件恢复失败:{err}") - - runtime_restored = False - registrations_restored = False - if runtime_touched: - try: - await self._plugin_reloader(plugin_id) - runtime_restored = True - except Exception as err: - errors.append(f"插件运行态恢复失败:{err}") - if runtime_restored: - try: - await self._registration_refresher(plugin_id) - registrations_restored = True - except Exception as err: - errors.append(f"插件路由和服务注册恢复失败:{err}") - - rollback = PluginInstallRollback( - file_attempted=True, - file_restored=file_restored, - installed_list_attempted=installed_list_persisted, - installed_list_restored=installed_list_restored, - runtime_attempted=runtime_touched, - runtime_restored=runtime_restored, - registrations_attempted=runtime_touched or registrations_touched, - registrations_restored=registrations_restored, - dependency_supported=False, - errors=tuple(errors), - ) + """补偿 PREPARED 事务,并把失败阶段与恢复结果返回调用方。""" + rollback = await self.__fail_prepared(plugin_id=plugin_id, state=state) return PluginInstallResult( success=False, message=message, - package_installed=package_installed, - installed_list_persisted=installed_list_persisted, + package_installed=state.package_installed, failure_stage=stage, rollback=rollback, ) + + async def __fail_prepared( + self, + *, + plugin_id: str, + state: _InstallState, + ) -> PluginInstallRollback: + """恢复数据库提交前的文件和运行态,成功后删除 PREPARED journal。""" + errors: list[str] = [] + file_restored = False + journal_deleted = False + try: + await self.__packages.async_restore(state.checkpoint) + file_restored = True + except Exception as error: # noqa: BLE001 - 保留 journal 供下次启动重试 + errors.append(f"插件文件恢复失败:{error}") + + runtime_restored = False + registrations_restored = False + if file_restored and state.runtime_touched: + try: + await self.__rollback_reloader(plugin_id) + runtime_restored = True + except Exception as error: # noqa: BLE001 - 返回完整补偿诊断 + errors.append(f"插件运行态恢复失败:{error}") + if runtime_restored: + try: + await self.__registration_refresher(plugin_id) + registrations_restored = True + except Exception as error: # noqa: BLE001 + errors.append(f"插件注册恢复失败:{error}") + + rollback_complete = file_restored and ( + not state.runtime_touched + or (runtime_restored and registrations_restored) + ) + if rollback_complete and state.journal_created: + try: + await self.__persistence.delete_installation( + state.transaction_id, + expected_phase=PluginInstallationPhase.PREPARED, + ) + journal_deleted = True + except Exception as error: # noqa: BLE001 - marker 让恢复可安全重试 + errors.append(f"插件安装事务删除失败:{error}") + if journal_deleted: + try: + await self.__packages.async_cleanup(state.checkpoint) + except Exception as error: # noqa: BLE001 - orphan 不再影响业务终态 + errors.append(f"插件恢复材料清理失败:{error}") + + return PluginInstallRollback( + file_attempted=state.checkpoint is not None, + file_restored=file_restored, + runtime_attempted=state.runtime_touched, + runtime_restored=runtime_restored, + registrations_attempted=( + state.runtime_touched or state.registrations_touched + ), + registrations_restored=registrations_restored, + journal_deleted=journal_deleted, + errors=tuple(errors), + ) + + async def __rollback_without_journal(self, checkpoint: Any) -> PluginInstallRollback: + """journal 创建失败时恢复文件并立即清理无主快照。""" + errors: list[str] = [] + try: + await self.__packages.async_restore(checkpoint) + except Exception as error: # noqa: BLE001 + return PluginInstallRollback( + file_attempted=True, + errors=(f"插件文件恢复失败:{error}",), + ) + try: + await self.__packages.async_cleanup(checkpoint) + except Exception as error: # noqa: BLE001 - 无 journal 的孤儿不影响旧载荷 + errors.append(f"插件恢复材料清理失败:{error}") + return PluginInstallRollback( + file_attempted=True, + file_restored=True, + errors=tuple(errors), + ) + + async def __rollback_cancelled( + self, + *, + plugin_id: str, + state: _InstallState, + ) -> None: + """保留取消语义,但不在补偿完成前释放插件生命周期 owner。""" + if state.committed: + logger.warning( + "插件 %s 在数据库提交后被取消,安装终态将在启动时继续清理", + plugin_id, + ) + return + if state.commit_unknown: + logger.error( + "插件 %s 在数据库提交结果未知时被取消,保留 journal 和当前载荷等待启动恢复", + plugin_id, + ) + return + if state.journal_unknown: + logger.error( + "插件 %s 在 PREPARED 创建结果未知时被取消,保留恢复材料等待人工或启动核对", + plugin_id, + ) + return + if state.checkpoint is None: + return + cleanup_task = asyncio.create_task( + self.__fail_prepared(plugin_id=plugin_id, state=state) + if state.journal_created + else self.__rollback_without_journal(state.checkpoint) + ) + try: + rollback = await await_task_to_terminal(cleanup_task) + except BaseException as error: + logger.error("插件 %s 取消后的补偿失败:%s", plugin_id, error) + return + if rollback.errors: + logger.error( + "插件 %s 取消后的补偿存在错误:%s", + plugin_id, + ";".join(rollback.errors), + ) + + @staticmethod + async def __await_side_effect(operation: Awaitable[T]) -> T: + """让不可安全中断的副作用进入终态后再传播调用方取消。""" + task = asyncio.ensure_future(operation) + try: + return await asyncio.shield(task) + except asyncio.CancelledError as cancellation: + try: + await await_task_to_terminal(task) + except BaseException as error: + raise cancellation from error + raise + + async def __report( + self, + plugin_id: str, + repo_url: str | None, + *, + enabled: bool, + ) -> tuple[bool, str]: + """执行非关键远程上报,不改变本地安装终态。""" + if not enabled: + return False, "" + try: + result = await self.__install_reporter(plugin_id, repo_url) + return result is not False, "" if result is not False else "安装上报未确认" + except Exception as error: # noqa: BLE001 - 远程上报不回滚本地安装 + return False, str(error) diff --git a/app/application/plugin/inventory.py b/app/application/plugin/inventory.py new file mode 100644 index 000000000..6cb9135c4 --- /dev/null +++ b/app/application/plugin/inventory.py @@ -0,0 +1,430 @@ +"""插件市场候选库存读取与外部事实映射。""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence +from typing import Any, TypeAlias +from urllib.parse import unquote, urlsplit + +from app.application.plugin.identity import ( + OFFICIAL_PLUGIN_SOURCE_KEY, + TrustedPluginSourceType, + validate_online_source_key, +) +from app.application.plugin.source import ( + CandidateInventory, + LocalCandidateRead, + MarketRead, + PluginLocalCandidate, + PluginMarketCandidate, + normalize_package_generation, +) + +PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1") +PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]] +PluginIndexLoaderResult: TypeAlias = PluginIndex | None +LocalCandidateLoadPayload: TypeAlias = ( + Mapping[str, Mapping[str, Any]] + | Iterable[Mapping[str, Any]] + | None +) +MarketLoader: TypeAlias = Callable[ + [str, str | None, bool], + PluginIndexLoaderResult, +] +AsyncMarketLoader: TypeAlias = Callable[ + [str, str | None, bool], + Awaitable[PluginIndexLoaderResult], +] +LocalCandidateLoader: TypeAlias = Callable[ + [], + LocalCandidateLoadPayload, +] + + +class PluginCandidateInventoryReader: + """按配置市场和 V3 代际顺序保留全部候选及读取终态。""" + + def __init__( + self, + *, + market_loader: MarketLoader, + local_candidate_loader: LocalCandidateLoader | None = None, + async_market_loader: AsyncMarketLoader | None = None, + generations: Sequence[str] = PLUGIN_V3_GENERATIONS, + max_concurrency: int = 12, + ) -> None: + """保存读取端口,并限制异步市场请求的进程内并发。""" + normalized_generations = tuple( + normalize_package_generation(generation) + for generation in generations + ) + if normalized_generations != PLUGIN_V3_GENERATIONS: + raise ValueError("V3 候选库存必须按 v3、v2、v1 顺序读取") + if max_concurrency < 1: + raise ValueError("插件市场读取并发必须大于 0") + self._market_loader = market_loader + self._async_market_loader = async_market_loader + self._local_candidate_loader = local_candidate_loader + self._generations = normalized_generations + self._max_concurrency = max_concurrency + + def load( + self, + markets: Iterable[str], + *, + force: bool = False, + ) -> CandidateInventory: + """同步读取全部配置市场,不把失败市场伪装成空仓库。""" + normalized_markets = _normalize_markets(markets) + reads = tuple( + self._read_market_generation(market, generation, force=force) + for market in normalized_markets + for generation in self._generations + ) + local_read = self._load_local_candidates() + return CandidateInventory( + reads, + local_read.candidates, + local_read=local_read, + expected_markets=normalized_markets, + expected_generations=self._generations, + ) + + async def async_load( + self, + markets: Iterable[str], + *, + force: bool = False, + ) -> CandidateInventory: + """有界并发读取全部市场,同时保持配置与代际的稳定顺序。""" + normalized_markets = _normalize_markets(markets) + semaphore = asyncio.Semaphore(self._max_concurrency) + + async def read(market: str, generation: str) -> MarketRead: + async with semaphore: + return await self._async_read_market_generation( + market, + generation, + force=force, + ) + + tasks = [ + asyncio.create_task( + read(market, generation), + name="plugin.inventory.read", + ) + for market in normalized_markets + for generation in self._generations + ] + try: + reads = tuple(await asyncio.gather(*tasks)) if tasks else () + finally: + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + local_read = await asyncio.to_thread(self._load_local_candidates) + return CandidateInventory( + reads, + local_read.candidates, + local_read=local_read, + expected_markets=normalized_markets, + expected_generations=self._generations, + ) + + def _read_market_generation( + self, + market: str, + generation: str, + *, + force: bool, + ) -> MarketRead: + """同步读取一个市场代际并映射为候选事实。""" + try: + source_key, repo_url, source_type = _market_source(market) + payload = self._market_loader( + repo_url, + _package_version(generation), + force, + ) + return _successful_read( + market, + generation, + payload, + source_key=source_key, + source_type=source_type, + repo_url=repo_url, + ) + except Exception as error: # noqa: BLE001 - 失败事实必须进入快照 + return MarketRead.failure( + market, + _error_message(error), + package_generation=generation, + ) + + async def _async_read_market_generation( + self, + market: str, + generation: str, + *, + force: bool, + ) -> MarketRead: + """异步读取一个市场代际并映射为候选事实。""" + try: + source_key, repo_url, source_type = _market_source(market) + if self._async_market_loader is None: + payload = await asyncio.to_thread( + self._market_loader, + repo_url, + _package_version(generation), + force, + ) + else: + payload = await self._async_market_loader( + repo_url, + _package_version(generation), + force, + ) + return _successful_read( + market, + generation, + payload, + source_key=source_key, + source_type=source_type, + repo_url=repo_url, + ) + except Exception as error: # noqa: BLE001 - 失败事实必须进入快照 + return MarketRead.failure( + market, + _error_message(error), + package_generation=generation, + ) + + def _load_local_candidates(self) -> LocalCandidateRead: + """映射本地候选,并保留扫描失败而非伪装为空仓库。""" + if self._local_candidate_loader is None: + return LocalCandidateRead.absent() + try: + raw_candidates = self._local_candidate_loader() + if raw_candidates is None: + return LocalCandidateRead.failure( + "本地插件仓库读取未返回可判定结果" + ) + except Exception as error: # noqa: BLE001 - 失败事实必须进入快照 + return LocalCandidateRead.failure(_error_message(error)) + entries: Iterable[tuple[object, Mapping[str, Any]]] + try: + if isinstance(raw_candidates, Mapping): + entries = raw_candidates.items() + else: + entries = ( + (plugin_info.get("id"), plugin_info) + for plugin_info in raw_candidates + if isinstance(plugin_info, Mapping) + ) + + candidates: list[PluginLocalCandidate] = [] + for plugin_id, plugin_info in entries: + if not isinstance(plugin_id, str) or not isinstance(plugin_info, Mapping): + continue + try: + candidate = _local_candidate(plugin_id, plugin_info) + except (TypeError, ValueError): + continue + if candidate is not None: + candidates.append(candidate) + except Exception as error: # noqa: BLE001 - 迭代或映射失败也要保留状态 + return LocalCandidateRead.failure(_error_message(error)) + return LocalCandidateRead.present(candidates) + + +def build_plugin_candidate_inventory( + markets: Iterable[str], + *, + market_loader: MarketLoader, + local_candidate_loader: LocalCandidateLoader | None = None, + force: bool = False, +) -> CandidateInventory: + """使用注入的同步读取端口构建一次候选库存。""" + return PluginCandidateInventoryReader( + market_loader=market_loader, + local_candidate_loader=local_candidate_loader, + ).load(markets, force=force) + + +def normalize_github_plugin_source(value: str) -> tuple[str, str]: + """把 GitHub 仓库 URL 或来源键归一为持久来源键和公开地址。""" + normalized = str(value).strip().rstrip("/") + if normalized.lower().startswith("github:"): + source_key = validate_online_source_key(normalized) + owner, repository = source_key.removeprefix("github:").split("/", 1) + return source_key, f"https://github.com/{owner}/{repository}" + + parsed = urlsplit(normalized) + if parsed.scheme not in {"http", "https"} or parsed.hostname is None: + raise ValueError("插件市场必须是 GitHub 仓库地址") + if parsed.hostname.lower() != "github.com": + raise ValueError("插件市场必须使用 github.com 仓库地址") + parts = [unquote(part) for part in parsed.path.split("/") if part] + if len(parts) < 2: + raise ValueError("插件市场 GitHub 地址缺少 owner 或 repository") + owner, repository = parts[:2] + repository = repository.removesuffix(".git") + source_key = validate_online_source_key(f"github:{owner}/{repository}") + return source_key, f"https://github.com/{owner}/{repository}" + + +def _normalize_markets(markets: Iterable[str]) -> tuple[str, ...]: + """规范化并去除重复市场,保留无效配置供读取快照报错。""" + result: list[str] = [] + seen: set[str] = set() + for market in markets: + value = str(market).strip().rstrip("/") + if not value: + continue + key = value.lower() + if key in seen: + continue + seen.add(key) + result.append(value) + return tuple(result) + + +def _market_source( + market: str, +) -> tuple[str, str, TrustedPluginSourceType]: + """解析 GitHub 市场来源,并固定官方仓库分类。""" + source_key, repo_url = normalize_github_plugin_source(market) + source_type = ( + TrustedPluginSourceType.OFFICIAL + if source_key == OFFICIAL_PLUGIN_SOURCE_KEY + else TrustedPluginSourceType.THIRD_PARTY + ) + return source_key, repo_url, source_type + + +def _successful_read( + market: str, + generation: str, + payload: PluginIndexLoaderResult, + *, + source_key: str, + source_type: TrustedPluginSourceType, + repo_url: str, +) -> MarketRead: + """把 Adapter 读取结果映射为一个市场代际的三态事实。""" + if payload is None: + return MarketRead.absent( + market, + package_generation=generation, + ) + if not isinstance(payload, Mapping): + raise TypeError("插件市场索引必须是对象") + return MarketRead.present( + market, + _market_candidates( + payload, + source_key=source_key, + source_type=source_type, + repo_url=repo_url, + package_generation=generation, + ), + package_generation=generation, + ) + + +def _market_candidates( + payload: PluginIndex, + *, + source_key: str, + source_type: TrustedPluginSourceType, + repo_url: str, + package_generation: str, +) -> tuple[PluginMarketCandidate, ...]: + """过滤并映射一个代际索引中的 V3 可兼容条目。""" + result: list[PluginMarketCandidate] = [] + for index_plugin_id, raw_info in payload.items(): + if not isinstance(raw_info, Mapping): + continue + plugin_id = raw_info.get("id") or index_plugin_id + if not isinstance(plugin_id, str): + continue + if not _is_v3_compatible(raw_info, package_generation): + continue + plugin_version = raw_info.get("version") + if plugin_version is None: + plugin_version = raw_info.get("plugin_version") + if plugin_version == "": + plugin_version = None + try: + result.append( + PluginMarketCandidate( + plugin_id=plugin_id, + source_key=source_key, + source_type=source_type, + repo_url=repo_url, + package_generation=package_generation, + plugin_version=plugin_version, + dto=dict(raw_info), + ) + ) + except (TypeError, ValueError): + continue + return tuple(result) + + +def _is_v3_compatible( + plugin_info: Mapping[str, Any], + package_generation: str, +) -> bool: + """按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。""" + if plugin_info.get("v3") is False: + return False + if package_generation in {"v3", "v2"}: + return True + return plugin_info.get("v3") is True or plugin_info.get("v2") is True + + +def _local_candidate( + plugin_id: str, + plugin_info: Mapping[str, Any], +) -> PluginLocalCandidate | None: + """把本地插件索引条目转换为应用候选。""" + generation = normalize_package_generation( + str( + plugin_info.get("package_version") + or plugin_info.get("package_generation") + or "v1" + ) + ) + if not _is_v3_compatible(plugin_info, generation): + return None + repo_url = plugin_info.get("repo_url") + if not isinstance(repo_url, str) or not repo_url.startswith("local://"): + return None + plugin_version = plugin_info.get("version") + if plugin_version is None: + plugin_version = plugin_info.get("plugin_version") + if plugin_version == "": + plugin_version = None + return PluginLocalCandidate( + plugin_id=plugin_id, + repo_url=repo_url, + package_generation=generation, + plugin_version=plugin_version, + dto=dict(plugin_info), + ) + + +def _package_version(package_generation: str) -> str | None: + """把公共代际转换为市场索引文件参数。""" + return None if package_generation == "v1" else package_generation + + +def _error_message(error: Exception) -> str: + """保留可诊断的读取失败说明,并避免空异常丢失状态。""" + message = str(error).strip() + return message or error.__class__.__name__ diff --git a/app/application/plugin/lifecycle.py b/app/application/plugin/lifecycle.py index 04b85a551..432b05ed1 100644 --- a/app/application/plugin/lifecycle.py +++ b/app/application/plugin/lifecycle.py @@ -4,9 +4,16 @@ from __future__ import annotations import asyncio import threading +from collections.abc import AsyncIterator from contextlib import asynccontextmanager +class PluginStartupLease: + """启动 lease 的不透明能力句柄,仅按对象身份由所属协调器认可。""" + + __slots__ = () + + class PluginLifecycleCoordinator: """在事件循环和同步启动线程之间协调插件生命周期操作。""" @@ -14,17 +21,29 @@ class PluginLifecycleCoordinator: self._condition = threading.Condition() self._active_plugins: set[str] = set() self._startup_active = False + self._startup_token: PluginStartupLease | None = None @staticmethod def _normalize(plugin_id: str) -> str: return (plugin_id or "").strip().lower() - def _try_acquire_plugin(self, plugin_id: str) -> bool: + def _try_acquire_plugin( + self, + plugin_id: str, + startup_token: PluginStartupLease | None = None, + ) -> bool: normalized_id = self._normalize(plugin_id) if not normalized_id: raise ValueError("插件ID不能为空") with self._condition: - if self._startup_active or normalized_id in self._active_plugins: + startup_token_matches = ( + startup_token is not None and startup_token is self._startup_token + ) + # 启动期间只有当前 lease 的显式 token 可以取得逐插件资格。 + if ( + normalized_id in self._active_plugins + or (self._startup_active and not startup_token_matches) + ): return False self._active_plugins.add(normalized_id) return True @@ -35,22 +54,32 @@ class PluginLifecycleCoordinator: self._active_plugins.discard(normalized_id) self._condition.notify_all() - def _try_acquire_startup(self) -> bool: + def _try_acquire_startup(self) -> PluginStartupLease | None: with self._condition: if self._startup_active or self._active_plugins: - return False + return None + startup_token = PluginStartupLease() self._startup_active = True - return True + self._startup_token = startup_token + return startup_token - def _release_startup(self) -> None: + def _release_startup(self, startup_token: PluginStartupLease) -> None: with self._condition: + # 延迟清理不得释放已经由新 owner 持有的启动 lease。 + if self._startup_token is not startup_token: + return + self._startup_token = None self._startup_active = False self._condition.notify_all() @asynccontextmanager - async def hold(self, plugin_id: str): + async def hold( + self, + plugin_id: str, + startup_token: PluginStartupLease | None = None, + ) -> AsyncIterator[None]: """异步持有单个插件的生命周期资格,不在线程池中等待锁。""" - while not self._try_acquire_plugin(plugin_id): + while not self._try_acquire_plugin(plugin_id, startup_token): await asyncio.sleep(0.01) try: yield @@ -58,14 +87,18 @@ class PluginLifecycleCoordinator: self._release_plugin(plugin_id) @asynccontextmanager - async def hold_startup(self): + async def hold_startup(self) -> AsyncIterator[PluginStartupLease]: """异步持有启动同步的全局资格,阻止安装请求穿过启动收口。""" - while not self._try_acquire_startup(): + startup_token: PluginStartupLease | None = None + while startup_token is None: + startup_token = self._try_acquire_startup() + if startup_token is not None: + break await asyncio.sleep(0.01) try: - yield + yield startup_token finally: - self._release_startup() + self._release_startup(startup_token) plugin_lifecycle = PluginLifecycleCoordinator() diff --git a/app/application/plugin/recovery.py b/app/application/plugin/recovery.py new file mode 100644 index 000000000..ebeef41bb --- /dev/null +++ b/app/application/plugin/recovery.py @@ -0,0 +1,189 @@ +"""插件安装 journal 的启动恢复与已提交事务收尾。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from app.application.plugin.install import ( + PluginPackageCheckpoint, + PluginPackageTransactionPort, +) +from app.application.plugin.transaction import ( + PluginInstallationPhase, + PluginInstallationRecord, + PluginPersistenceService, +) +from app.runtime.log import logger + + +class PluginInstallationRecoveryError(RuntimeError): + """恢复材料不足或已提交载荷事实不一致,不能继续导入插件。""" + + +class PluginRecoveryPackagePort(PluginPackageTransactionPort, Protocol): + """启动恢复在安装包事务端口之上需要的重建与核验能力。""" + + def restore_checkpoint( + self, + *, + plugin_id: str, + transaction_id: str, + plugin_existed: bool, + persistent_backup_existed: bool, + ) -> PluginPackageCheckpoint: + """只根据 journal 中的受限事实重建恢复路径。""" + + async def async_committed_payload_receipt( + self, + checkpoint: PluginPackageCheckpoint, + ) -> str: + """读取当前部署模式下一次已提交载荷的恢复收据。""" + + +@dataclass(frozen=True, slots=True) +class PluginInstallationRecoveryResult: + """启动恢复批次的 PREPARED 回滚和 COMMITTED 收尾数量。""" + + restored: int = 0 + finalized: int = 0 + cleanup_pending: int = 0 + + +class PluginInstallationRecoveryService: + """在插件导入前把跨进程 journal 收敛到完整旧状态或新状态。""" + + def __init__( + self, + *, + persistence: PluginPersistenceService, + packages: PluginRecoveryPackagePort, + ) -> None: + """保存数据库最终事实和文件恢复端口。""" + self.__persistence = persistence + self.__packages = packages + + async def replay(self) -> PluginInstallationRecoveryResult: + """按创建顺序恢复全部 journal;关键事实不一致时阻止插件启动。""" + restored = 0 + finalized = 0 + cleanup_pending = 0 + for record in await self.__persistence.list_installations(): + checkpoint = self.__checkpoint(record) + if record.phase is PluginInstallationPhase.PREPARED: + await self.__restore_prepared(record, checkpoint) + restored += 1 + continue + if await self.__finish_committed(record, checkpoint): + finalized += 1 + else: + cleanup_pending += 1 + return PluginInstallationRecoveryResult( + restored=restored, + finalized=finalized, + cleanup_pending=cleanup_pending, + ) + + def __checkpoint( + self, + record: PluginInstallationRecord, + ) -> PluginPackageCheckpoint: + """把 journal 映射为受控恢复路径,不读取任意持久化路径。""" + return self.__packages.restore_checkpoint( + plugin_id=record.plugin_id, + transaction_id=record.transaction_id, + plugin_existed=record.package_existed, + persistent_backup_existed=record.persistent_backup_existed, + ) + + async def __restore_prepared( + self, + record: PluginInstallationRecord, + checkpoint: PluginPackageCheckpoint, + ) -> None: + """恢复数据库提交前的文件状态,再释放 journal 所有权。""" + try: + await self.__packages.async_restore(checkpoint) + await self.__persistence.delete_installation( + record.transaction_id, + expected_phase=PluginInstallationPhase.PREPARED, + ) + except Exception as error: + raise PluginInstallationRecoveryError( + f"插件 {record.plugin_id} 的未提交安装恢复失败:{error}" + ) from error + try: + await self.__packages.async_cleanup(checkpoint) + except Exception as error: # journal 已删除,孤儿材料不改变业务终态 + logger.warning( + "插件安装事务 %s 已恢复,但恢复材料清理失败:%s", + record.transaction_id, + error, + ) + + async def __finish_committed( + self, + record: PluginInstallationRecord, + checkpoint: PluginPackageCheckpoint, + ) -> bool: + """核验并收尾已提交载荷;非关键清理失败留待下一次启动。""" + identity = await self.__persistence.get_identity(record.plugin_id) + if identity is None or identity.revision != record.identity_target_revision: + raise PluginInstallationRecoveryError( + f"插件 {record.plugin_id} 的已提交身份与安装 journal 不一致" + ) + try: + receipt = await self.__packages.async_committed_payload_receipt( + checkpoint + ) + except Exception as error: + raise PluginInstallationRecoveryError( + f"插件 {record.plugin_id} 的已提交载荷无法核验:{error}" + ) from error + if receipt != identity.payload_receipt: + raise PluginInstallationRecoveryError( + f"插件 {record.plugin_id} 的已提交载荷收据不一致" + ) + try: + await self.__packages.async_finalize_persistent_backup(checkpoint) + except Exception as error: + raise PluginInstallationRecoveryError( + f"插件 {record.plugin_id} 的持久备份终态不完整:{error}" + ) from error + try: + await self.__packages.async_commit(checkpoint) + await self.__persistence.delete_installation( + record.transaction_id, + expected_phase=PluginInstallationPhase.COMMITTED, + ) + except Exception as error: + logger.warning( + "插件安装事务 %s 已提交但收尾仍待重试:%s", + record.transaction_id, + error, + ) + return False + return True + + +_RECOVERY_SERVICE: list[PluginInstallationRecoveryService] = [] + + +def configure_plugin_installation_recovery( + service: PluginInstallationRecoveryService, +) -> None: + """由组合根登记当前 lifespan 的安装恢复服务。""" + _RECOVERY_SERVICE.clear() + _RECOVERY_SERVICE.append(service) + + +def get_plugin_installation_recovery() -> PluginInstallationRecoveryService: + """返回已装配恢复服务;启动顺序错误时拒绝跳过恢复。""" + if not _RECOVERY_SERVICE: + raise RuntimeError("插件安装恢复服务尚未完成初始化") + return _RECOVERY_SERVICE[0] + + +def reset_plugin_installation_recovery() -> None: + """清除当前 lifespan 的恢复服务。""" + _RECOVERY_SERVICE.clear() diff --git a/app/application/plugin/source.py b/app/application/plugin/source.py new file mode 100644 index 000000000..07d9929fd --- /dev/null +++ b/app/application/plugin/source.py @@ -0,0 +1,824 @@ +"""插件市场候选事实与来源选择策略。""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, TypeAlias +from urllib.parse import unquote, urlsplit + +from app.application.plugin.identity import ( + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, + normalize_physical_plugin_id, + validate_online_source_key, +) +from app.application.plugin.identity import ( + PluginSourceCandidate as IdentitySourceCandidate, +) +from app.foundation.version import compare_version + +PLUGIN_GENERATIONS = ("v1", "v2", "v3") + + +class MarketReadStatus(StrEnum): + """一次市场索引读取的最终状态。""" + + PRESENT = "present" + ABSENT = "absent" + FAILED = "failed" + +class PluginSelectionStatus(StrEnum): + """插件候选选择的可观察结果。""" + + SELECTED = "selected" + UNAVAILABLE = "unavailable" + CONFLICT = "conflict" + INCOMPLETE = "incomplete" + + +class PluginSourceSelectionError(RuntimeError): + """插件来源选择的策略错误。""" + + +@dataclass(frozen=True, slots=True) +class PluginMarketCandidate: + """一个在线市场条目的原始候选事实。""" + + plugin_id: str + source_key: str + source_type: TrustedPluginSourceType + repo_url: str + package_generation: str + plugin_version: str | None + dto: Any = None + normalized_plugin_id: str = field(init=False) + + def __post_init__(self) -> None: + """校验候选身份,并把外部来源键归一为持久化合同使用的形式。""" + normalized_id = normalize_physical_plugin_id(self.plugin_id) + source_type = _coerce_online_source_type(self.source_type) + source_key = validate_online_source_key(self.source_key) + # IdentitySourceCandidate 复用官方仓库与来源类型的双向约束。 + IdentitySourceCandidate(source_type=source_type, source_key=source_key) + repo_url = _normalize_repo_url(self.repo_url) + package_generation = normalize_package_generation(self.package_generation) + plugin_version = _normalize_plugin_version(self.plugin_version) + object.__setattr__(self, "normalized_plugin_id", normalized_id) + object.__setattr__(self, "source_type", source_type) + object.__setattr__(self, "source_key", source_key) + object.__setattr__(self, "repo_url", repo_url) + object.__setattr__(self, "package_generation", package_generation) + object.__setattr__(self, "plugin_version", plugin_version) + + @property + def payload_source_type(self) -> PluginPayloadSourceType: + """返回用于载荷审计的在线来源类型。""" + return PluginPayloadSourceType(self.source_type.value) + + def public_dict(self) -> dict[str, Any]: + """生成不携带原始元数据的公共候选投影。""" + return { + "plugin_id": self.plugin_id, + "source_key": self.source_key, + "source_type": self.source_type.value, + "repo_url": self.repo_url, + "package_generation": self.package_generation, + "plugin_version": self.plugin_version, + } + +@dataclass(frozen=True, slots=True) +class PluginLocalCandidate: + """一个本地插件载荷候选,与在线来源身份保持独立。""" + + plugin_id: str + repo_url: str + package_generation: str + plugin_version: str | None + dto: Any = None + normalized_plugin_id: str = field(init=False) + + def __post_init__(self) -> None: + """校验本地载荷的插件与版本事实。""" + normalized_id = normalize_physical_plugin_id(self.plugin_id) + repo_url = _normalize_repo_url(self.repo_url) + package_generation = normalize_package_generation(self.package_generation) + plugin_version = _normalize_plugin_version(self.plugin_version) + object.__setattr__(self, "normalized_plugin_id", normalized_id) + object.__setattr__(self, "repo_url", repo_url) + object.__setattr__(self, "package_generation", package_generation) + object.__setattr__(self, "plugin_version", plugin_version) + + @property + def payload_source_type(self) -> PluginPayloadSourceType: + """返回本地载荷类型,不把本地路径伪装成在线来源。""" + return PluginPayloadSourceType.LOCAL + + @property + def source_type(self) -> PluginPayloadSourceType: + """返回独立的本地载荷类型,不伪造在线可信来源。""" + return PluginPayloadSourceType.LOCAL + + @property + def source_key(self) -> None: + """本地载荷没有可绑定的在线来源键。""" + return None + + def public_dict(self) -> dict[str, Any]: + """生成本地候选的公共投影,永不暴露仓库路径或原始 metadata。""" + return { + "plugin_id": self.plugin_id, + "source_type": PluginPayloadSourceType.LOCAL.value, + "package_generation": self.package_generation, + "plugin_version": self.plugin_version, + } + +@dataclass(frozen=True, slots=True) +class MarketRead: + """记录一个配置市场的读取状态及其全部在线候选。""" + + market: str + status: MarketReadStatus + candidates: tuple[PluginMarketCandidate, ...] = () + error: str | None = None + package_generation: str = "v1" + + def __post_init__(self) -> None: + """拒绝把失败读取伪装成空成功结果。""" + market = _normalize_market(self.market) + status = MarketReadStatus(self.status) + candidates = tuple(self.candidates) + package_generation = normalize_package_generation(self.package_generation) + if status is MarketReadStatus.FAILED: + if candidates: + raise ValueError("失败的插件市场读取不能携带候选") + if not self.error or not self.error.strip(): + raise ValueError("失败的插件市场读取必须保留错误说明") + else: + if self.error: + raise ValueError("已判定的插件市场读取不能携带错误说明") + if status is MarketReadStatus.ABSENT and candidates: + raise ValueError("不存在的插件市场索引不能携带候选") + if any(not isinstance(candidate, PluginMarketCandidate) for candidate in candidates): + raise TypeError("市场读取候选必须是在线插件候选") + object.__setattr__(self, "market", market) + object.__setattr__(self, "status", status) + object.__setattr__(self, "candidates", candidates) + object.__setattr__(self, "error", self.error.strip() if self.error else None) + object.__setattr__(self, "package_generation", package_generation) + + @classmethod + def present( + cls, + market: str, + candidates: Iterable[PluginMarketCandidate] = (), + *, + package_generation: str = "v1", + ) -> "MarketRead": + """构造存在的索引读取;真实空索引可以没有候选。""" + return cls( + market=market, + status=MarketReadStatus.PRESENT, + candidates=tuple(candidates), + package_generation=package_generation, + ) + + @classmethod + def absent( + cls, + market: str, + *, + package_generation: str = "v1", + ) -> "MarketRead": + """构造已确认不存在的代际索引。""" + return cls( + market=market, + status=MarketReadStatus.ABSENT, + package_generation=package_generation, + ) + + @classmethod + def failure( + cls, + market: str, + error: str, + *, + package_generation: str = "v1", + ) -> "MarketRead": + """构造失败读取,并保留可诊断但不用于选择的错误说明。""" + return cls( + market=market, + status=MarketReadStatus.FAILED, + error=error, + package_generation=package_generation, + ) + + @property + def succeeded(self) -> bool: + """判断该索引是否得到存在或不存在的确定结论。""" + return self.status is not MarketReadStatus.FAILED + + @property + def present_index(self) -> bool: + """判断该代际索引是否真实存在。""" + return self.status is MarketReadStatus.PRESENT + + def public_dict(self) -> dict[str, Any]: + """生成市场读取的脱敏投影,保留状态、代际和候选事实。""" + return { + "market": self.market, + "package_generation": self.package_generation, + "status": self.status.value, + "error": self.error, + "candidates": [candidate.public_dict() for candidate in self.candidates], + } + + +class LocalCandidateReadStatus(StrEnum): + """一次本地插件仓库扫描的可观察终态。""" + + PRESENT = "present" + ABSENT = "absent" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class LocalCandidateRead: + """记录本地候选扫描状态,避免扫描失败伪装成空仓库。""" + + status: LocalCandidateReadStatus + candidates: tuple[PluginLocalCandidate, ...] = () + error: str | None = None + + def __post_init__(self) -> None: + """保证本地扫描状态、候选和错误说明相互一致。""" + status = LocalCandidateReadStatus(self.status) + candidates = tuple(self.candidates) + if any(not isinstance(candidate, PluginLocalCandidate) for candidate in candidates): + raise TypeError("本地扫描候选必须是 PluginLocalCandidate") + if status is LocalCandidateReadStatus.FAILED: + if candidates: + raise ValueError("失败的本地扫描不能携带候选") + if not self.error or not self.error.strip(): + raise ValueError("失败的本地扫描必须保留错误说明") + else: + if self.error: + raise ValueError("已判定的本地扫描不能携带错误说明") + if status is LocalCandidateReadStatus.ABSENT and candidates: + raise ValueError("不存在的本地扫描不能携带候选") + object.__setattr__(self, "status", status) + object.__setattr__(self, "candidates", candidates) + object.__setattr__(self, "error", self.error.strip() if self.error else None) + + @classmethod + def present( + cls, + candidates: Iterable[PluginLocalCandidate] = (), + ) -> "LocalCandidateRead": + """构造扫描成功的本地候选快照,空结果仍表示扫描成功。""" + return cls( + status=LocalCandidateReadStatus.PRESENT, + candidates=tuple(candidates), + ) + + @classmethod + def absent(cls) -> "LocalCandidateRead": + """构造没有配置本地仓库的结果。""" + return cls(status=LocalCandidateReadStatus.ABSENT) + + @classmethod + def failure(cls, error: str) -> "LocalCandidateRead": + """构造无法完成本地扫描的结果。""" + return cls(status=LocalCandidateReadStatus.FAILED, error=error) + + def public_dict(self) -> dict[str, Any]: + """生成不泄漏本地路径的扫描投影。""" + return { + "status": self.status.value, + "error": self.error, + "candidates": [candidate.public_dict() for candidate in self.candidates], + } + + +@dataclass(frozen=True, slots=True) +class CandidateInventory: + """一次短生命周期市场快照,保留配置市场状态和全部候选。""" + + market_reads: tuple[MarketRead, ...] + local_candidates: tuple[PluginLocalCandidate, ...] = () + expected_markets: tuple[str, ...] | None = None + expected_generations: tuple[str, ...] | None = None + local_read: LocalCandidateRead | None = None + + def __post_init__(self) -> None: + """冻结快照输入,避免后续市场刷新改变选择依据。""" + market_reads = tuple(self.market_reads) + local_candidates = tuple(self.local_candidates) + expected_markets = ( + tuple(_normalize_market(market) for market in self.expected_markets) + if self.expected_markets is not None + else None + ) + expected_generations = ( + tuple(normalize_package_generation(generation) for generation in self.expected_generations) + if self.expected_generations is not None + else None + ) + local_read = self.local_read + if local_read is None: + local_read = ( + LocalCandidateRead.present(local_candidates) + if local_candidates + else LocalCandidateRead.absent() + ) + if not isinstance(local_read, LocalCandidateRead): + raise TypeError("候选清单的本地读取必须由 LocalCandidateRead 组成") + if local_read.candidates != local_candidates: + raise ValueError("候选清单的本地读取与本地候选必须一致") + if any(not isinstance(read, MarketRead) for read in market_reads): + raise TypeError("候选清单必须由 MarketRead 组成") + if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local_candidates): + raise TypeError("本地候选清单必须由 PluginLocalCandidate 组成") + read_keys = [(read.market, read.package_generation) for read in market_reads] + if len(read_keys) != len(set(read_keys)): + raise ValueError("候选清单不能重复记录同一个市场代际") + if expected_markets is not None and len(expected_markets) != len(set(expected_markets)): + raise ValueError("候选清单的预期市场不能重复") + if expected_generations is not None: + if not expected_generations or len(expected_generations) != len(set(expected_generations)): + raise ValueError("候选清单的预期代际必须唯一且非空") + object.__setattr__(self, "market_reads", market_reads) + object.__setattr__(self, "local_candidates", local_candidates) + object.__setattr__(self, "expected_markets", expected_markets) + object.__setattr__(self, "expected_generations", expected_generations) + object.__setattr__(self, "local_read", local_read) + + @property + def configured_markets(self) -> tuple[str, ...]: + """按配置顺序返回本轮预期读取的市场。""" + if self.expected_markets is not None: + return self.expected_markets + return tuple(dict.fromkeys(read.market for read in self.market_reads)) + + def reads_for(self, market: str) -> tuple[MarketRead, ...]: + """返回一个市场的全部代际读取事实。""" + normalized_market = _normalize_market(market) + return tuple(read for read in self.market_reads if read.market == normalized_market) + + def read_for(self, market: str, package_generation: str) -> MarketRead | None: + """返回一个市场和代际的读取事实。""" + normalized_generation = normalize_package_generation(package_generation) + return next( + ( + read + for read in self.reads_for(market) + if read.package_generation == normalized_generation + ), + None, + ) + + @property + def complete(self) -> bool: + """只有预期市场与代际均可证明已成功读取时才算完整。""" + if not self.market_reads or not all(read.succeeded for read in self.market_reads): + return False + expected_markets = self.expected_markets + expected_generations = self.expected_generations + if (expected_markets is None) != (expected_generations is None): + return False + if expected_markets is None or expected_generations is None: + return True + expected = { + (market, generation) + for market in expected_markets + for generation in expected_generations + } + actual = {(read.market, read.package_generation) for read in self.market_reads} + return expected <= actual + + @property + def can_use_for_tofu(self) -> bool: + """判断快照是否足以证明唯一第三方来源。""" + local_read = self.local_read + return ( + self.complete + and local_read is not None + and local_read.status is not LocalCandidateReadStatus.FAILED + ) + + @property + def online_candidates(self) -> tuple[PluginMarketCandidate, ...]: + """按配置市场顺序返回全部在线候选,不按 ID 或版本去重。""" + return tuple( + candidate + for read in self.market_reads + if read.present_index + for candidate in read.candidates + ) + + def candidates_for(self, plugin_id: str) -> tuple[PluginMarketCandidate, ...]: + """读取一个插件 ID 的全部在线候选。""" + normalized_id = normalize_physical_plugin_id(plugin_id) + return tuple( + candidate + for candidate in self.online_candidates + if candidate.normalized_plugin_id == normalized_id + ) + + def local_candidates_for(self, plugin_id: str) -> tuple[PluginLocalCandidate, ...]: + """读取一个插件 ID 的全部本地候选。""" + normalized_id = normalize_physical_plugin_id(plugin_id) + return tuple( + candidate + for candidate in self.local_candidates + if candidate.normalized_plugin_id == normalized_id + ) + + def public_dict(self) -> dict[str, Any]: + """生成完整库存的脱敏投影,不泄漏本地路径或原始 DTO。""" + local_read = self.local_read + if local_read is None: + raise RuntimeError("候选清单缺少本地读取终态") + return { + "markets": [read.public_dict() for read in self.market_reads], + "local_candidates": [candidate.public_dict() for candidate in self.local_candidates], + "local_read": local_read.public_dict(), + "complete": self.complete, + } + +@dataclass(frozen=True, slots=True) +class PluginSelection: + """候选选择结果,冲突和不完整状态均不降级为静默空值。""" + + status: PluginSelectionStatus + candidate: PluginMarketCandidate | PluginLocalCandidate | None = None + conflict_source_keys: tuple[str, ...] = () + reason: str = "" + + def __post_init__(self) -> None: + """保证选择状态与载荷及冲突信息相互一致。""" + status = PluginSelectionStatus(self.status) + conflict_source_keys = tuple(sorted(set(self.conflict_source_keys))) + if status is PluginSelectionStatus.SELECTED and self.candidate is None: + raise ValueError("selected 结果必须携带候选") + if status is not PluginSelectionStatus.SELECTED and self.candidate is not None: + raise ValueError("未选中结果不能携带候选") + if status is not PluginSelectionStatus.CONFLICT and conflict_source_keys: + raise ValueError("只有 conflict 结果能携带冲突来源") + object.__setattr__(self, "status", status) + object.__setattr__(self, "conflict_source_keys", conflict_source_keys) + + @property + def selected(self) -> bool: + """判断是否已经选择出一个载荷候选。""" + return self.status is PluginSelectionStatus.SELECTED + + def public_dict(self) -> dict[str, Any]: + """生成安全的选择结果投影,不透传本地路径或原始 DTO。""" + result: dict[str, Any] = { + "status": self.status.value, + "reason": self.reason, + } + if self.conflict_source_keys: + result["conflict_source_keys"] = list(self.conflict_source_keys) + if self.candidate is not None: + result["candidate"] = self.candidate.public_dict() + return result + +Candidate: TypeAlias = PluginMarketCandidate | PluginLocalCandidate + + +def normalize_package_generation(package_generation: str) -> str: + """校验并归一插件包代际。""" + value = str(package_generation).strip().lower() + if value not in PLUGIN_GENERATIONS: + raise ValueError("插件包代际必须为 v1、v2 或 v3") + return value + + +def _select_local_candidate( + inventory: CandidateInventory, + *, + plugin_id: str, + normalized_id: str, + generation_order: tuple[str, ...], + local_candidates: Iterable[PluginLocalCandidate] | None, +) -> PluginSelection | None: + """优先选择本地载荷;读取失败时阻止自动降级到在线来源。""" + local = ( + tuple(local_candidates) + if local_candidates is not None + else inventory.local_candidates_for(plugin_id) + ) + if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local): + raise TypeError("本地候选必须是 PluginLocalCandidate") + if any(candidate.normalized_plugin_id != normalized_id for candidate in local): + raise ValueError("本地候选的插件 ID 必须与选择目标一致") + local_read = inventory.local_read + if ( + not local + and local_read is not None + and local_read.status is LocalCandidateReadStatus.FAILED + ): + return PluginSelection( + status=PluginSelectionStatus.INCOMPLETE, + reason="本地插件仓库读取失败,不能自动选择在线载荷", + ) + if not local: + return None + selected_local = _select_best(local, generation_order) + if selected_local is None: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="本地候选没有符合当前运行代际的版本", + ) + return PluginSelection( + status=PluginSelectionStatus.SELECTED, + candidate=selected_local, + reason="优先使用本地载荷", + ) + + +def select_plugin_candidate( + inventory: CandidateInventory, + *, + plugin_id: str, + generations: Sequence[str], + identity: PluginIdentity | None = None, + local_candidates: Iterable[PluginLocalCandidate] | None = None, + requested_source_key: str | None = None, + explicit_source: bool = False, + allow_source_change: bool = False, +) -> PluginSelection: + """ + 按允许来源、运行代际和同源版本选择一个插件载荷。 + + :param inventory: 本轮市场读取快照 + :param plugin_id: 要选择的物理插件 ID + :param generations: 调用方按优先级传入的代际顺序 + :param identity: 已安装插件来源身份;为空表示未安装 + :param local_candidates: 可选的本地载荷候选,优先于在线候选 + :param requested_source_key: 调用方提供的规范在线来源;非显式调用不能绕过本地载荷 + :param explicit_source: 本次调用是否代表管理员明确选源 + :param allow_source_change: 是否是带 revision 的显式换源命令 + :return: 带明确冲突或不完整状态的选择结果 + """ + normalized_id = normalize_physical_plugin_id(plugin_id) + generation_order = _normalize_generation_order(generations) + requested_source = ( + validate_online_source_key(requested_source_key) + if requested_source_key is not None + else None + ) + if requested_source is None or not (explicit_source or allow_source_change): + local_selection = _select_local_candidate( + inventory, + plugin_id=plugin_id, + normalized_id=normalized_id, + generation_order=generation_order, + local_candidates=local_candidates, + ) + if local_selection is not None: + return local_selection + + online = inventory.candidates_for(plugin_id) + if not online: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason=f"没有找到插件 {plugin_id} 的在线候选", + ) + + allowed_source = _allowed_source(identity, normalized_id) + if requested_source is not None: + requested_online = tuple( + candidate + for candidate in online + if candidate.source_key == requested_source + ) + if not requested_online: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="明确选择的在线来源没有当前插件候选", + ) + if allowed_source is not None: + _source_type, allowed_key = allowed_source + if requested_source != allowed_key and not allow_source_change: + return PluginSelection( + status=PluginSelectionStatus.CONFLICT, + conflict_source_keys=(allowed_key, requested_source), + reason="普通安装不能改变已绑定的在线来源", + ) + if explicit_source or allow_source_change: + selected_requested = _select_best(requested_online, generation_order) + if selected_requested is None: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="明确选择的来源没有符合当前运行代际的版本", + ) + return PluginSelection( + status=PluginSelectionStatus.SELECTED, + candidate=selected_requested, + reason=( + "按显式换源目标选择在线载荷" + if allow_source_change + else "按管理员明确选择的来源安装在线载荷" + ), + ) + if allowed_source is not None: + source_type, source_key = allowed_source + online = tuple( + candidate + for candidate in online + if candidate.source_type is source_type and candidate.source_key == source_key + ) + if not online: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="当前来源身份没有可用候选", + ) + selected_online = _select_best(online, generation_order) + if selected_online is None: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="在线候选没有符合当前运行代际的版本", + ) + return PluginSelection( + status=PluginSelectionStatus.SELECTED, + candidate=selected_online, + reason="按已绑定来源选择在线载荷", + ) + + if identity is not None: + return PluginSelection( + status=PluginSelectionStatus.INCOMPLETE, + reason="插件来源身份尚未绑定,不能自动选择在线载荷", + ) + + source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online} + if len(source_pairs) > 1: + return PluginSelection( + status=PluginSelectionStatus.CONFLICT, + conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs), + reason="未安装插件存在多个在线来源,不能静默选择", + ) + + source_type = next(iter(source_pairs))[0] + if source_type is TrustedPluginSourceType.THIRD_PARTY and not inventory.can_use_for_tofu: + return PluginSelection( + status=PluginSelectionStatus.INCOMPLETE, + reason="市场读取不完整,不能建立唯一第三方来源的 TOFU", + ) + selected_online = _select_best(online, generation_order) + if selected_online is None: + return PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="在线候选没有符合当前运行代际的版本", + ) + return PluginSelection( + status=PluginSelectionStatus.SELECTED, + candidate=selected_online, + reason="唯一在线来源候选", + ) + + +def list_effective_online_candidates( + inventory: CandidateInventory, + *, + plugin_id: str, + generations: Sequence[str], +) -> tuple[PluginMarketCandidate, ...]: + """按来源列出当前运行代际实际可安装的最高版本候选。""" + generation_order = _normalize_generation_order(generations) + grouped: dict[ + tuple[TrustedPluginSourceType, str], + list[PluginMarketCandidate], + ] = {} + for candidate in inventory.candidates_for(plugin_id): + grouped.setdefault( + (candidate.source_type, candidate.source_key), + [], + ).append(candidate) + + selected: list[PluginMarketCandidate] = [] + for candidates in grouped.values(): + selected_candidate = _select_best(candidates, generation_order) + if isinstance(selected_candidate, PluginMarketCandidate): + selected.append(selected_candidate) + return tuple(selected) + + +def get_effective_local_candidate( + inventory: CandidateInventory, + *, + plugin_id: str, + generations: Sequence[str], +) -> PluginLocalCandidate | None: + """返回本地插件目录中当前运行代际优先级最高的安全候选。""" + candidate = _select_best( + inventory.local_candidates_for(plugin_id), + _normalize_generation_order(generations), + ) + return candidate if isinstance(candidate, PluginLocalCandidate) else None + + +def parse_local_plugin_reference(repo_url: str) -> str | None: + """从不透明本地来源标识中提取插件 ID,不读取或暴露宿主路径。""" + if not str(repo_url).startswith("local://"): + return None + try: + parsed = urlsplit(repo_url) + plugin_id = unquote(parsed.netloc or parsed.path.strip("/")) + except (TypeError, ValueError): + return None + return plugin_id or None + + +def _coerce_online_source_type(source_type: TrustedPluginSourceType) -> TrustedPluginSourceType: + """把外部字符串来源类型转换为可信在线来源枚举。""" + value = TrustedPluginSourceType(source_type) + if value is TrustedPluginSourceType.UNKNOWN: + raise ValueError("未知来源不能作为在线市场候选") + return value + + +def _normalize_market(market: str) -> str: + """校验市场标识,保留其作为本轮快照的显示值。""" + value = str(market).strip() + if not value: + raise ValueError("插件市场标识不能为空") + return value + + +def _normalize_repo_url(repo_url: str) -> str: + """保留仓库地址作为安装事实,但移除无意义的外围空白。""" + value = str(repo_url).strip() + if not value: + raise ValueError("插件仓库地址不能为空") + return value + + +def _normalize_plugin_version(plugin_version: str | None) -> str | None: + """标准化可选插件声明版本,并保持缺失版本可观察。""" + if plugin_version is None: + return None + value = str(plugin_version).strip() + if not value: + raise ValueError("插件声明版本不能为空字符串") + if len(value) > 64: + raise ValueError("插件声明版本长度不能超过 64") + return value + + +def _normalize_generation_order(generations: Sequence[str]) -> tuple[str, ...]: + """校验调用方提供的代际优先序,并拒绝重复项。""" + normalized = tuple(normalize_package_generation(generation) for generation in generations) + if not normalized: + raise ValueError("至少需要一个当前运行代际") + if len(normalized) != len(set(normalized)): + raise ValueError("当前运行代际优先序不能重复") + return normalized + + +def _allowed_source( + identity: PluginIdentity | None, + normalized_plugin_id: str, +) -> tuple[TrustedPluginSourceType, str] | None: + """从已安装身份读取不可变的允许在线来源。""" + if identity is None: + return None + if identity.normalized_plugin_id != normalized_plugin_id: + raise ValueError("来源身份的插件 ID 与选择目标不一致") + if identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN: + return None + if not identity.trusted_source_key: + raise PluginSourceSelectionError("已绑定来源身份缺少规范来源键") + return identity.trusted_source_type, identity.trusted_source_key + + +def _select_best( + candidates: Sequence[Candidate], + generation_order: Sequence[str], +) -> Candidate | None: + """在已完成来源过滤后按代际和同源版本选择最高候选。""" + for generation in generation_order: + generation_candidates = tuple( + candidate + for candidate in candidates + if candidate.package_generation == generation + ) + if generation_candidates: + return _select_highest_version(generation_candidates) + return None + + +def _select_highest_version(candidates: Sequence[Candidate]) -> Candidate: + """使用宿主既有版本比较语义选择同源最高版本,平级保留先读候选。""" + selected = candidates[0] + for candidate in candidates[1:]: + selected_version = selected.plugin_version or "0" + candidate_version = candidate.plugin_version or "0" + if compare_version(candidate_version, ">", selected_version): + selected = candidate + return selected diff --git a/app/application/plugin/transaction.py b/app/application/plugin/transaction.py new file mode 100644 index 000000000..bb93fd0d4 --- /dev/null +++ b/app/application/plugin/transaction.py @@ -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() diff --git a/app/db/adapters/pluginidentity.py b/app/db/adapters/pluginidentity.py index 4f47518ce..bcda8ed9c 100644 --- a/app/db/adapters/pluginidentity.py +++ b/app/db/adapters/pluginidentity.py @@ -7,6 +7,9 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.application.plugin.identity import ( + BindLocalPluginIdentityCommand, + BindOnlinePluginIdentityCommand, + ChangePluginIdentitySourceCommand, PluginBindingBasis, PluginIdentity, PluginIdentityConflictError, @@ -143,3 +146,51 @@ class TransactionalPluginIdentityStore: ).execute(identity, expected_revision=expected_revision) finally: session.close() + + def change_source( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """在独占事务内提交明确的在线来源转换。""" + session = self._session_factory() + try: + return ChangePluginIdentitySourceCommand( + repository=_SqlAlchemyIdentityRepository(session), + unit_of_work=SqlAlchemyUnitOfWork(session), + ).execute(identity, expected_revision=expected_revision) + finally: + session.close() + + def bind_local( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """在独占事务内提交 legacy_unbound 到 local_only 的转换。""" + session = self._session_factory() + try: + return BindLocalPluginIdentityCommand( + repository=_SqlAlchemyIdentityRepository(session), + unit_of_work=SqlAlchemyUnitOfWork(session), + ).execute(identity, expected_revision=expected_revision) + finally: + session.close() + + def bind_online( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """在独占事务内提交未绑定身份的首次在线来源绑定。""" + session = self._session_factory() + try: + return BindOnlinePluginIdentityCommand( + repository=_SqlAlchemyIdentityRepository(session), + unit_of_work=SqlAlchemyUnitOfWork(session), + ).execute(identity, expected_revision=expected_revision) + finally: + session.close() diff --git a/app/db/adapters/plugininstallation.py b/app/db/adapters/plugininstallation.py new file mode 100644 index 000000000..780163b8e --- /dev/null +++ b/app/db/adapters/plugininstallation.py @@ -0,0 +1,506 @@ +"""插件安装事务 Application Port 的同步 SQLAlchemy 实现。""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +from typing import cast + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.transaction import ( + PluginInstallationConflictError, + PluginInstallationPhase, + PluginInstallationRecord, + PluginInstallationStore, +) +from app.db.models.pluginidentity import PluginIdentity as IdentityModel +from app.db.models.plugininstallation import PluginInstallation + +_INSTALLED_PLUGINS_KEY = "UserInstalledPlugins" +AtomicMembershipUpdater = Callable[ + [ + str, + Callable[ + [Session, object], + tuple[PluginInstallationRecord, object], + ], + ], + PluginInstallationRecord, +] + + +def _identity_from_model(model: IdentityModel) -> PluginIdentity: + """把同一 Session 读出的身份模型还原为应用记录。""" + return PluginIdentity( + plugin_id=model.plugin_id, + normalized_plugin_id=model.normalized_plugin_id, + trusted_source_type=TrustedPluginSourceType(model.trusted_source_type), + trusted_source_key=model.trusted_source_key, + binding_basis=PluginBindingBasis(model.binding_basis), + payload_source_type=PluginPayloadSourceType(model.payload_source_type), + payload_source_key=model.payload_source_key, + declared_version=model.declared_version, + package_generation=model.package_generation, + system_version=model.system_version, + supports_v3=model.supports_v3, + supports_v3t=model.supports_v3t, + payload_receipt=model.payload_receipt, + revision=model.revision, + created_at=datetime.fromisoformat(model.created_at), + updated_at=datetime.fromisoformat(model.updated_at), + bound_at=( + datetime.fromisoformat(model.bound_at) + if model.bound_at + else None + ), + payload_applied_at=( + datetime.fromisoformat(model.payload_applied_at) + if model.payload_applied_at + else None + ), + ) + + +def _identity_model_values(identity: PluginIdentity) -> dict[str, object]: + """把应用身份映射为不含自增主键的模型列值。""" + return { + "plugin_id": identity.plugin_id, + "normalized_plugin_id": identity.normalized_plugin_id, + "trusted_source_type": identity.trusted_source_type.value, + "trusted_source_key": identity.trusted_source_key, + "binding_basis": identity.binding_basis.value, + "payload_source_type": identity.payload_source_type.value, + "payload_source_key": identity.payload_source_key, + "declared_version": identity.declared_version, + "package_generation": identity.package_generation, + "system_version": identity.system_version, + "supports_v3": identity.supports_v3, + "supports_v3t": identity.supports_v3t, + "payload_receipt": identity.payload_receipt, + "revision": identity.revision, + "created_at": identity.created_at.isoformat(), + "updated_at": identity.updated_at.isoformat(), + "bound_at": identity.bound_at.isoformat() if identity.bound_at else None, + "payload_applied_at": ( + identity.payload_applied_at.isoformat() + if identity.payload_applied_at + else None + ), + } + + +class TransactionalPluginInstallationStore(PluginInstallationStore): + """以单张事务表协调单插件 membership、来源身份和 phase。""" + + def __init__( + self, + session_factory: Callable[[], Session], + update_membership_atomically: AtomicMembershipUpdater, + ) -> None: + """保存事务会话工厂和配置 membership 的窄原子写入口。""" + self._session_factory = session_factory + self.__update_membership_atomically = update_membership_atomically + + def __session(self) -> Session: + """创建不会在提交后过期状态的短生命周期 Session。""" + session = self._session_factory() + session.expire_on_commit = False + return session + + @staticmethod + def __now() -> str: + """生成带时区的持久化更新时间。""" + return datetime.now(timezone.utc).isoformat() + + @staticmethod + def __phase(value: PluginInstallationPhase | str) -> PluginInstallationPhase: + """把调用方 phase 转为受限枚举。""" + try: + return ( + value + if isinstance(value, PluginInstallationPhase) + else PluginInstallationPhase(value) + ) + except ValueError as error: + raise PluginInstallationConflictError( + f"未知插件安装 phase: {value}" + ) from error + + @staticmethod + def __to_record(model: PluginInstallation) -> PluginInstallationRecord: + """把 ORM 行还原为经过应用层校验的事务记录。""" + try: + return PluginInstallationRecord( + transaction_id=model.transaction_id, + plugin_id=model.plugin_id, + phase=PluginInstallationPhase(model.phase), + membership_before=model.membership_before, + membership_target=model.membership_target, + identity_before_revision=model.identity_before_revision, + identity_target_revision=model.identity_target_revision, + package_existed=model.package_existed, + persistent_backup_existed=model.persistent_backup_existed, + created_at=datetime.fromisoformat(model.created_at), + updated_at=datetime.fromisoformat(model.updated_at), + schema_version=model.schema_version, + ) + except (TypeError, ValueError) as error: + raise PluginInstallationConflictError( + f"插件安装事务 {model.transaction_id} 的持久化状态无效" + ) from error + + @staticmethod + def __identity_query(session: Session, plugin_id: str) -> IdentityModel | None: + """读取并锁定指定插件的身份行。""" + return session.execute( + select(IdentityModel) + .where(IdentityModel.normalized_plugin_id == plugin_id.lower()) + .with_for_update() + ).scalar_one_or_none() + + @staticmethod + def __membership_state(current: object, plugin_id: str) -> bool: + """只读取目标插件 membership,不把其他插件写入事务快照。""" + if current is None: + return False + if not isinstance(current, list) or any( + not isinstance(item, str) for item in current + ): + raise PluginInstallationConflictError( + "UserInstalledPlugins 当前值不是 JSON 字符串数组" + ) + normalized_id = plugin_id.lower() + return any(item.lower() == normalized_id for item in current) + + @staticmethod + def __write_membership( + current: object, + plugin_id: str, + target: bool, + ) -> list[str]: + """在配置写锁内只增删目标插件,保留其他插件并发变更。""" + if current is None: + values: list[str] = [] + elif isinstance(current, list) and all( + isinstance(item, str) for item in current + ): + values = list(current) + else: + raise PluginInstallationConflictError( + "UserInstalledPlugins 当前值不是 JSON 字符串数组" + ) + + normalized_id = plugin_id.lower() + values = [item for item in values if item.lower() != normalized_id] + if target: + values.append(plugin_id) + return values + + @classmethod + def __identity_revision( + cls, + session: Session, + plugin_id: str, + ) -> int | None: + """读取锁定身份行的 revision;缺行表示 CAS 的 null。""" + identity = cls.__identity_query(session, plugin_id) + return identity.revision if identity is not None else None + + @classmethod + def __write_identity( + cls, + session: Session, + plugin_id: str, + identity: PluginIdentity | None, + ) -> None: + """在调用方事务中写入或删除目标插件身份。""" + current = cls.__identity_query(session, plugin_id) + if identity is None: + if current is not None: + session.delete(current) + return + if identity.plugin_id != plugin_id: + raise PluginInstallationConflictError( + "PluginIdentity target 与事务 plugin_id 不一致" + ) + values = _identity_model_values(identity) + if current is None: + session.add(IdentityModel(**values)) + else: + for key, value in values.items(): + setattr(current, key, value) + + @staticmethod + def __assert_target_identity( + record: PluginInstallationRecord, + identity: PluginIdentity, + ) -> None: + """确认目标身份属于当前插件且 revision 只前进一步。""" + if identity.plugin_id != record.plugin_id: + raise PluginInstallationConflictError( + "PluginIdentity target 与事务 plugin_id 不一致" + ) + expected_revision = (record.identity_before_revision or 0) + 1 + if identity.revision != expected_revision: + raise PluginInstallationConflictError( + f"事务 {record.transaction_id} 的 target identity revision " + f"必须为 {expected_revision}" + ) + + @staticmethod + def __require_row(session: Session, transaction_id: str) -> PluginInstallation: + """读取并锁定事务行,缺失时拒绝继续写入。""" + row = session.execute( + select(PluginInstallation) + .where(PluginInstallation.transaction_id == transaction_id) + .with_for_update() + ).scalar_one_or_none() + if row is None: + raise PluginInstallationConflictError( + f"插件安装事务不存在: {transaction_id}" + ) + return cast(PluginInstallation, row) + + @classmethod + def __check_phase( + cls, + row: PluginInstallation, + expected_phase: PluginInstallationPhase | str, + ) -> PluginInstallationPhase: + """执行写操作共用的 phase CAS。""" + expected = cls.__phase(expected_phase) + try: + actual = PluginInstallationPhase(row.phase) + except ValueError as error: + raise PluginInstallationConflictError( + f"事务 {row.transaction_id} 的 phase 无效: {row.phase}" + ) from error + if actual is not expected: + raise PluginInstallationConflictError( + f"事务 {row.transaction_id} phase 已变化: " + f"expected={expected.value}, actual={actual.value}" + ) + return actual + + @classmethod + def __assert_before_state( + cls, + record: PluginInstallationRecord, + session: Session, + current_membership: object, + ) -> None: + """确认目标插件仍处于事务创建时的 before 状态。""" + membership = cls.__membership_state(current_membership, record.plugin_id) + revision = cls.__identity_revision(session, record.plugin_id) + if membership != record.membership_before: + raise PluginInstallationConflictError( + f"事务 {record.transaction_id} 的插件 membership 发生漂移" + ) + if revision != record.identity_before_revision: + raise PluginInstallationConflictError( + f"事务 {record.transaction_id} 的插件身份 revision 发生漂移" + ) + + def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord: + """原子预留单插件 journal 槽位并立即 flush 唯一键竞争。""" + def reserve( + session: Session, + current_membership: object, + ) -> tuple[PluginInstallationRecord, object]: + """在配置写事务内阻断同一物理插件的未收尾 journal。""" + existing = session.execute( + select(PluginInstallation) + .where( + func.lower(PluginInstallation.plugin_id) + == record.plugin_id.lower() + ) + .with_for_update() + ).scalars().first() + if existing is not None: + raise PluginInstallationConflictError( + f"插件 {record.plugin_id} 存在未收尾安装事务: " + f"{existing.transaction_id} ({existing.phase})" + ) + session.add( + PluginInstallation( + transaction_id=record.transaction_id, + plugin_id=record.plugin_id, + phase=record.phase.value, + membership_before=record.membership_before, + membership_target=record.membership_target, + identity_before_revision=record.identity_before_revision, + identity_target_revision=record.identity_target_revision, + package_existed=record.package_existed, + persistent_backup_existed=record.persistent_backup_existed, + created_at=record.created_at.isoformat(), + updated_at=record.updated_at.isoformat(), + schema_version=record.schema_version, + ) + ) + session.flush() + return record, current_membership + + try: + return cast( + PluginInstallationRecord, + self.__update_membership_atomically( + _INSTALLED_PLUGINS_KEY, + reserve, + ), + ) + except IntegrityError as error: + raise PluginInstallationConflictError( + f"插件安装事务创建发生并发竞争: {record.transaction_id}" + ) from error + + def get(self, transaction_id: str) -> PluginInstallationRecord | None: + """按事务 ID 读取记录。""" + session = self.__session() + try: + row = session.execute( + select(PluginInstallation).where( + PluginInstallation.transaction_id == transaction_id + ) + ).scalar_one_or_none() + return self.__to_record(row) if row else None + finally: + session.close() + + def list( + self, + *, + plugin_id: str | None = None, + ) -> list[PluginInstallationRecord]: + """按创建时间稳定列出事务记录。""" + session = self.__session() + try: + statement = select(PluginInstallation).order_by( + PluginInstallation.created_at, + PluginInstallation.transaction_id, + ) + if plugin_id is not None: + statement = statement.where(PluginInstallation.plugin_id == plugin_id) + return [ + self.__to_record(row) + for row in session.execute(statement).scalars() + ] + finally: + session.close() + + def set_target( + self, + transaction_id: str, + *, + membership_target: bool, + identity_target: PluginIdentity | None, + expected_phase: PluginInstallationPhase, + ) -> PluginInstallationRecord: + """按 phase CAS 登记目标 membership 和身份 revision,不写业务状态。""" + if not isinstance(membership_target, bool): + raise PluginInstallationConflictError("membership_target 必须是布尔值") + session = self.__session() + try: + with session.begin(): + row = self.__require_row(session, transaction_id) + self.__check_phase(row, expected_phase) + record = self.__to_record(row) + if identity_target is not None: + self.__assert_target_identity(record, identity_target) + row.membership_target = membership_target + row.identity_target_revision = ( + identity_target.revision if identity_target is not None else None + ) + row.updated_at = self.__now() + session.flush() + return self.__to_record(row) + finally: + session.close() + + def commit_target( + self, + transaction_id: str, + *, + identity_target: PluginIdentity | None, + expected_phase: PluginInstallationPhase, + ) -> PluginInstallationRecord: + """原子提交目标 membership、身份 CAS 和 COMMITTED phase。""" + def commit( + session: Session, + current_membership: object, + ) -> tuple[PluginInstallationRecord, list[str]]: + """在配置行锁持有期间完成事务行、身份和 membership 写入。""" + row = self.__require_row(session, transaction_id) + self.__check_phase(row, expected_phase) + record = self.__to_record(row) + if record.membership_target is None: + raise PluginInstallationConflictError( + f"事务 {transaction_id} 尚未设置 membership target" + ) + if identity_target is not None: + self.__assert_target_identity(record, identity_target) + if identity_target.revision != record.identity_target_revision: + raise PluginInstallationConflictError( + f"事务 {transaction_id} 的 target identity revision 不匹配" + ) + elif record.identity_target_revision is not None: + raise PluginInstallationConflictError( + f"事务 {transaction_id} 缺少 target identity" + ) + + self.__assert_before_state(record, session, current_membership) + updated_membership = self.__write_membership( + current_membership, + record.plugin_id, + record.membership_target, + ) + self.__write_identity(session, record.plugin_id, identity_target) + row.phase = PluginInstallationPhase.COMMITTED.value + row.updated_at = self.__now() + session.flush() + return self.__to_record(row), updated_membership + + try: + return cast( + PluginInstallationRecord, + self.__update_membership_atomically( + _INSTALLED_PLUGINS_KEY, + commit, + ), + ) + except IntegrityError as error: + raise PluginInstallationConflictError( + f"插件 {transaction_id} 的身份提交发生唯一键竞争" + ) from error + + def delete( + self, + transaction_id: str, + *, + expected_phase: PluginInstallationPhase, + ) -> bool: + """按 phase CAS 删除事务记录;缺失记录按幂等删除处理。""" + session = self.__session() + try: + with session.begin(): + row = session.execute( + select(PluginInstallation) + .where(PluginInstallation.transaction_id == transaction_id) + .with_for_update() + ).scalar_one_or_none() + if row is None: + return False + self.__check_phase(row, expected_phase) + session.delete(row) + session.flush() + return True + finally: + session.close() diff --git a/app/db/models/__init__.py b/app/db/models/__init__.py index c575b510f..38aa37e98 100644 --- a/app/db/models/__init__.py +++ b/app/db/models/__init__.py @@ -18,6 +18,10 @@ _MODEL_EXPORTS = { "OutboxMessage": ("app.db.models.outbox", "OutboxMessage"), "PassKey": ("app.db.models.passkey", "PassKey"), "PluginData": ("app.db.models.plugindata", "PluginData"), + "PluginInstallation": ( + "app.db.models.plugininstallation", + "PluginInstallation", + ), "PluginIdentity": ( "app.db.models.pluginidentity", "PluginIdentity", diff --git a/app/db/models/plugininstallation.py b/app/db/models/plugininstallation.py new file mode 100644 index 000000000..37fe85315 --- /dev/null +++ b/app/db/models/plugininstallation.py @@ -0,0 +1,35 @@ +"""插件安装事务的单表持久化模型。""" + +from typing import Optional + +from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, get_id_column + + +class PluginInstallation(Base): + """保存单插件 membership、身份 CAS revision 和持久备份状态。""" + + id = get_id_column() + transaction_id: Mapped[str] = mapped_column(String(128), nullable=False) + plugin_id: Mapped[str] = mapped_column(String(128), nullable=False) + phase: Mapped[str] = mapped_column(String(16), nullable=False) + membership_before: Mapped[bool] = mapped_column(Boolean, nullable=False) + membership_target: Mapped[Optional[bool]] = mapped_column(Boolean) + identity_before_revision: Mapped[Optional[int]] = mapped_column(Integer) + identity_target_revision: Mapped[Optional[int]] = mapped_column(Integer) + package_existed: Mapped[bool] = mapped_column(Boolean, nullable=False) + persistent_backup_existed: Mapped[bool] = mapped_column(Boolean, nullable=False) + created_at: Mapped[str] = mapped_column(String(40), nullable=False) + updated_at: Mapped[str] = mapped_column(String(40), nullable=False) + schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + __table_args__ = ( + UniqueConstraint( + "transaction_id", + name="uq_plugininstallation_transaction_id", + ), + Index("ix_plugininstallation_plugin_id", "plugin_id"), + Index("ix_plugininstallation_phase", "phase"), + ) diff --git a/app/db/oper/systemconfig.py b/app/db/oper/systemconfig.py index ad9789c56..1e352854a 100644 --- a/app/db/oper/systemconfig.py +++ b/app/db/oper/systemconfig.py @@ -1,7 +1,9 @@ import copy import threading -from typing import Any, Optional, Union +from collections.abc import Callable +from typing import Any, Optional, TypeVar, Union +from sqlalchemy import select from sqlalchemy.orm import Session from app.db.base import DbOper @@ -9,6 +11,8 @@ from app.db.models.systemconfig import SystemConfig from app.schemas.types import SystemConfigKey from app.foundation.singleton import Singleton +T = TypeVar("T") + class SystemConfigOper(DbOper, metaclass=Singleton): """ @@ -80,6 +84,37 @@ class SystemConfigOper(DbOper, metaclass=Singleton): self._publish_value(key, value) return result + def update_atomically( + self, + key: Union[str, SystemConfigKey], + mutation: Callable[[Session, Any], tuple[T, Any]], + ) -> T: + """在配置写锁内提交关联记录,并在事务成功后发布最终配置值。""" + if isinstance(key, SystemConfigKey): + key = key.value + self._require_loaded() + with self._write_lock: + + def write(db: Session) -> tuple[T, Any]: + """锁定配置行,把关联写入与最终配置值放入同一事务。""" + conf = db.execute( + select(SystemConfig) + .where(SystemConfig.key == key) + .with_for_update() + ).scalar_one_or_none() + current = copy.deepcopy(conf.value if conf else None) + result, value = mutation(db, current) + committed_value = copy.deepcopy(value) + if conf: + conf.value = committed_value + else: + db.add(SystemConfig(key=key, value=committed_value)) + return result, committed_value + + result, committed_value = self._execute_sync_write(write) + self._publish_value(key, committed_value) + return result + def get(self, key: Optional[Union[str, SystemConfigKey]] = None) -> Any: """ 获取系统设置 diff --git a/app/locales/en-US.json b/app/locales/en-US.json index cb3efa4fe..24e07daca 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -359,6 +359,7 @@ "文件列表为空": "File list is empty", "requirements.txt 文件下载失败": "Failed to download requirements.txt", "插件在仓库中不存在或返回数据格式不正确": "The plugin does not exist in the repository or the returned data format is invalid", + "插件来源身份不存在": "Plugin source identity does not exist", "插件数据解析失败": "Failed to parse plugin data", "没有传入需要安装的依赖项": "No dependencies to install were provided", "资产缺少ID信息": "Asset is missing ID information", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index c1d34e3f3..ac5fb49d4 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -353,6 +353,7 @@ "文件列表为空": "檔案清單為空", "requirements.txt 文件下载失败": "requirements.txt 檔案下載失敗", "插件在仓库中不存在或返回数据格式不正确": "插件在倉庫中不存在或返回資料格式不正確", + "插件来源身份不存在": "插件來源身分不存在", "插件数据解析失败": "插件資料解析失敗", "没有传入需要安装的依赖项": "未傳入需要安裝的依賴項", "资产缺少ID信息": "資產缺少 ID 資訊", diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index 7b6e26140..60032a900 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -153,7 +153,7 @@ class PluginCatalogFacade: plugin_info=info, market=self._system().local_repo_url( plugin_id, - info.get("repo_path"), + None, package_version, ), installed_apps=installed, diff --git a/app/runtime/extensions/plugin/sync.py b/app/runtime/extensions/plugin/sync.py index e284078c8..db5ef8fce 100644 --- a/app/runtime/extensions/plugin/sync.py +++ b/app/runtime/extensions/plugin/sync.py @@ -4,7 +4,6 @@ from __future__ import annotations import time from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path from typing import Any, Callable, Optional from app.runtime.extensions.plugin.system import PluginSystemServices @@ -22,8 +21,7 @@ class PluginSyncService: local_plugins: Callable[[], list[Any]], merge_plugins: Callable[[list[Any], list[Any], list[Any]], list[Any]], plugin_exists: Callable[[str, Optional[str]], bool], - install: Callable[[str, Optional[str], bool], tuple[bool, str]], - report: Callable[..., Any], + install: Callable[[str, Optional[str], bool, object | None], tuple[bool, str]], log: Any, ) -> None: """保存目录读取、包安装和持久化报告端口。""" @@ -34,24 +32,38 @@ class PluginSyncService: self._merge_plugins = merge_plugins self._plugin_exists = plugin_exists self._install = install - self._report = report self._logger = log - def sync(self) -> list[str]: - """并发安装本地缺失或需要更新的已安装插件。""" + def sync( + self, + startup_token: object | None = None, + *, + online_restore_plugins: set[str] | None = None, + ) -> list[str]: + """并发安装本地缺失、需要更新或应恢复在线载荷的插件。""" if self._frozen(): return [] installed = self._installed_plugins() online = self._online_plugins() local = self._local_plugins() + local_plugin_ids = {plugin.id.lower() for plugin in local} + restore_plugin_ids = { + plugin_id.lower() + for plugin_id in (online_restore_plugins or set()) + } - local_plugin_ids candidates = self._merge_plugins(online + local, [], []) if online or local else [] targets = [ plugin for plugin in candidates if plugin.id in installed - and plugin.system_version_compatible is not False - and not self._plugin_exists(plugin.id, plugin.plugin_version) + and ( + plugin.id.lower() in restore_plugin_ids + or ( + plugin.system_version_compatible is not False + and not self._plugin_exists(plugin.id, plugin.plugin_version) + ) + ) ] if not targets: return [] @@ -63,10 +75,14 @@ class PluginSyncService: def install_one(plugin: Any) -> None: """安装一个插件并记录结果。""" started = time.time() - state, message = self._install(plugin.id, plugin.repo_url, False) + state, message = self._install( + plugin.id, + None, + False, + startup_token, + ) elapsed = time.time() - started if state: - self._report(plugin_id=plugin.id, repo_url=plugin.repo_url) self._logger.info( f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version}," f"耗时:{elapsed:.2f} 秒" @@ -128,12 +144,24 @@ class LocalPluginSyncService: f"{candidate.get('skip_reason')}" ) return False - source_dir = Path(candidate.get("path")) + repo_url = candidate.get("repo_url") + if not isinstance(repo_url, str) or not repo_url.startswith("local://"): + self._logger.error(f"本地插件 {plugin_id} 缺少可验证的本地来源标识") + return False try: - if not self._system().package.sync_local(plugin_id, source_dir): + state, message = self._system().install_plugin( + plugin_id=plugin_id, + repo_url=repo_url, + package_version=candidate.get("package_version") or None, + force=True, + local_sync=True, + explicit_source=True, + ) + if not state: + self._logger.error(f"同步本地插件 {plugin_id} 失败:{message}") return False self._recent_sync[plugin_id] = time.time() - self._logger.info(f"已同步本地插件 {plugin_id}:{source_dir}") + self._logger.info(f"已同步本地插件 {plugin_id}") return True except Exception as error: self._logger.error(f"同步本地插件 {plugin_id} 失败:{error}") diff --git a/app/runtime/extensions/plugin/system.py b/app/runtime/extensions/plugin/system.py index 3e63a9eae..0dc06ea20 100644 --- a/app/runtime/extensions/plugin/system.py +++ b/app/runtime/extensions/plugin/system.py @@ -19,14 +19,16 @@ class PluginSystemServices: dependency_manifest_status: Callable[[Path], Optional[bool]], compatible_flags: Callable[[Optional[str]], list[str]], frozen: Callable[[], bool], + install: Callable[..., tuple[bool, str]], ) -> None: - """记录市场、包、依赖和代际兼容计算端口。""" + """记录市场、包、安装 Gateway、依赖和代际兼容计算端口。""" self.market = market self.package = package self.dependency = dependency self.dependency_manifest_status = dependency_manifest_status self.compatible_flags = compatible_flags self.frozen = frozen + self.install = install def local_repo_paths(self) -> list[Path]: """返回可监测的本地插件仓库路径。""" @@ -65,6 +67,30 @@ class PluginSystemServices: """判断当前宿主是否为不可写的冻结运行模式。""" return self.frozen() + def install_plugin( + self, + *, + plugin_id: str, + repo_url: str | None, + package_version: str | None = None, + release_version: str | None = None, + force: bool = False, + local_sync: bool = False, + explicit_source: bool = False, + startup_token: object | None = None, + ) -> tuple[bool, str]: + """从同步运行时线程进入宿主唯一安装 Gateway。""" + return self.install( + plugin_id=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force=force, + local_sync=local_sync, + explicit_source=explicit_source, + startup_token=startup_token, + ) + _services: Optional[PluginSystemServices] = None diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 9c92bae9e..9c36091a3 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -68,7 +68,6 @@ from app.schemas.types import EventType, SystemConfigKey LegacyDiagnosticsConfigurator = Callable[..., None] LegacyImportScanner = Callable[..., None] LegacyPluginImportPreparer = Callable[..., None] -PluginInstallReporter = Callable[..., None] SiteAuthLevelProvider = Callable[[], int] PluginCatalogFactory = Callable[["PluginManager"], Any] PluginRouteRefresher = Callable[[str], None] @@ -122,7 +121,6 @@ _legacy_import_scanner: LegacyImportScanner = _ignore_legacy_diagnostics _legacy_plugin_import_preparer: LegacyPluginImportPreparer = ( _ignore_plugin_resource_imports ) -_plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics _site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level _plugin_catalog_factory: PluginCatalogFactory = _unavailable_plugin_catalog_factory _plugin_route_refresher: PluginRouteRefresher = _unavailable_plugin_route_refresher @@ -147,12 +145,6 @@ def configure_plugin_resource_import_preparer( _legacy_plugin_import_preparer = preparer -def configure_plugin_install_reporter(reporter: PluginInstallReporter) -> None: - """由启动组合根注入插件安装上报器,避免扩展层依赖远程服务。""" - global _plugin_install_reporter - _plugin_install_reporter = reporter - - def configure_site_auth_level_provider(provider: SiteAuthLevelProvider) -> None: """由启动组合根注入站点认证等级,避免扩展运行时依赖应用服务。""" global _site_auth_level_provider @@ -317,12 +309,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): plugin_id, version, ), - install=lambda plugin_id, repo_url, force: get_plugin_system().package.install( + install=lambda plugin_id, repo_url, force, startup_token: get_plugin_system().install_plugin( plugin_id=plugin_id, repo_url=repo_url, - force_install=force, + force=force, + startup_token=startup_token, ), - report=lambda **kwargs: _plugin_install_reporter(**kwargs), log=logger, ) self._plugin_clone = PluginCloneService( @@ -826,13 +818,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): log=logger, ).clear_modules(plugin_id) - def sync(self) -> List[str]: + def sync( + self, + startup_token: object | None = None, + *, + online_restore_plugins: set[str] | None = None, + ) -> List[str]: """ 安装本地不存在或需要更新的插件 """ with self.mutation("同步插件包"): - return self._plugin_sync.sync() + return self._plugin_sync.sync( + startup_token, + online_restore_plugins=online_restore_plugins, + ) @staticmethod def install_plugin_missing_dependencies() -> List[str]: diff --git a/app/schemas/exports.py b/app/schemas/exports.py index dda210a94..a42ef9b80 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -292,6 +292,11 @@ SCHEMA_EXPORTS = { 'PluginRuntimeStatus': ('app.schemas.plugin', 'PluginRuntimeStatus'), 'PluginRuntimeSummary': ('app.schemas.plugin', 'PluginRuntimeSummary'), 'PluginSidebarNavItem': ('app.schemas.plugin', 'PluginSidebarNavItem'), + 'PluginSourceCandidate': ('app.schemas.plugin', 'PluginSourceCandidate'), + 'PluginSourceChangeRequest': ('app.schemas.plugin', 'PluginSourceChangeRequest'), + 'PluginSourceIdentity': ('app.schemas.plugin', 'PluginSourceIdentity'), + 'PluginSourceInstallRequest': ('app.schemas.plugin', 'PluginSourceInstallRequest'), + 'PluginSourceOptions': ('app.schemas.plugin', 'PluginSourceOptions'), 'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'), 'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'), 'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 55b9f2c0a..c8d9b6a90 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -128,6 +128,118 @@ class PluginCloneRequest(BaseModel): ) +class PluginSourceIdentity(BaseModel): # type: ignore[misc] + """显式换源确认所需的插件来源身份投影。""" + + plugin_id: str = Field(description="物理插件 ID") + trusted_source_type: str = Field(description="当前可信在线来源类型") + trusted_source_key: Optional[str] = Field( + default=None, + description="规范化的可信在线来源键;未绑定时为空", + ) + binding_basis: str = Field(description="当前可信来源的建立依据") + payload_source_type: str = Field(description="最近一次已提交载荷的来源类型") + payload_source_key: Optional[str] = Field( + default=None, + description="最近一次在线载荷的来源键;本地或未知载荷为空", + ) + revision: int = Field(ge=1, description="显式换源使用的身份 CAS revision") + + +class PluginSourceCandidate(BaseModel): # type: ignore[misc] + """一个可供管理员识别的脱敏插件来源候选。""" + + source_type: Literal["official", "third_party", "local"] = Field( + description="来源类型;本地候选不公开路径" + ) + source_key: Optional[str] = Field( + default=None, + description="规范化在线来源键;本地候选为空", + ) + repo_url: Optional[str] = Field( + default=None, + description="可明确选择的在线仓库地址;本地候选为空", + ) + package_generation: Literal["v1", "v2", "v3"] = Field( + description="当前运行时会采用的插件包代际" + ) + plugin_version: Optional[str] = Field( + default=None, + description="该来源当前可安装的插件版本", + ) + + +class PluginSourceOptions(BaseModel): # type: ignore[misc] + """来源选择界面所需的当前身份、候选和准入状态。""" + + plugin_id: str = Field(description="物理插件 ID") + inventory_complete: bool = Field( + description="本轮配置市场是否全部得到确定读取结果" + ) + selection_status: Literal[ + "selected", "unavailable", "conflict", "incomplete" + ] = Field(description="未指定新来源时的当前准入状态") + selection_reason: str = Field(description="当前准入状态的人类可读原因") + identity: Optional[PluginSourceIdentity] = Field( + default=None, + description="已安装插件的来源身份;未建立身份时为空", + ) + candidates: List[PluginSourceCandidate] = Field( + default_factory=list, + description="按来源归并后的在线候选及可选本地候选", + ) + + +class PluginSourceInstallRequest(BaseModel): # type: ignore[misc] + """管理员为未绑定插件明确选择初始在线来源的请求参数。""" + + repo_url: str = Field(min_length=1, description="明确选择的目标插件仓库地址") + release_version: Optional[str] = Field( + default=None, + description="指定安装的 Release 资产版本;为空时使用当前索引版本", + ) + force: bool = Field( + default=False, + description="是否强制重新下载并安装所选来源载荷", + ) + + @field_validator("repo_url") # type: ignore[misc] + @classmethod + def normalize_repo_url(cls, value: str) -> str: + """拒绝只含空白或本地路径标识的来源选择。""" + normalized = value.strip() + if not normalized: + raise ValueError("显式安装必须指定目标在线来源") + if normalized.startswith("local://"): + raise ValueError("显式来源安装只接受在线插件仓库") + return normalized + + +class PluginSourceChangeRequest(BaseModel): # type: ignore[misc] + """管理员显式切换插件在线来源的请求参数。""" + + repo_url: str = Field(min_length=1, description="明确选择的目标插件仓库地址") + expected_revision: int = Field( + ge=1, + description="提交换源时必须匹配的当前身份 revision", + ) + release_version: Optional[str] = Field( + default=None, + description="指定安装的 Release 资产版本;为空时使用当前索引版本", + ) + + @field_validator("repo_url") # type: ignore[misc] + @classmethod + def normalize_repo_url(cls, value: str) -> str: + """拒绝只含空白或本地路径标识的换源目标。""" + normalized = value.strip() + if not normalized: + raise ValueError("显式换源必须指定目标在线来源") + if normalized.startswith("local://"): + raise ValueError("显式换源只接受在线插件仓库") + return normalized + + class PluginDashboard(Plugin): """ 插件仪表盘 diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index fe501f387..95486f1fc 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -4,8 +4,14 @@ import sys from typing import Callable from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper +from app.application.plugin.transaction import ( + PluginPersistenceService, + configure_plugin_persistence, +) from app.chain.mediaserver import MediaServerChain from app.chain.tmdb import TmdbChain +from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore +from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore # SitesHelper涉及资源包拉取,提前引入并容错提示 try: @@ -198,7 +204,7 @@ async def stop_database_worker() -> None: async def _initialize_configuration_services( database_worker: DatabaseWorker, -) -> None: +) -> SystemConfigOper: """加载完整配置快照后发布系统与用户配置服务。""" system_config = SystemConfigOper() user_config = UserConfigOper() @@ -216,6 +222,7 @@ async def _initialize_configuration_services( async_executor=database_worker, ) ) + return system_config def _build_runtime_settings_service() -> RuntimeSettingsService: @@ -699,13 +706,23 @@ async def init_modules() -> HostRuntime: await database_worker.start() _database_worker = database_worker try: - await _initialize_configuration_services(database_worker) + system_config = await _initialize_configuration_services(database_worker) except BaseException: try: await stop_database_worker() except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常 logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}") raise + configure_plugin_persistence( + PluginPersistenceService( + executor=database_worker, + identities=TransactionalPluginIdentityStore(SessionFactory), + installations=TransactionalPluginInstallationStore( + SessionFactory, + system_config.update_atomically, + ), + ) + ) # 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。 api_data = ApiDataPorts( sync_session=get_db, diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 4441974ed..e8e750d27 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -1,6 +1,37 @@ +import asyncio +import uuid +from datetime import datetime, timezone from pathlib import Path +from app.application.commands import init_commands +from app.application.plugin.gateway import ( + PluginInstallGateway, + configure_plugin_install_service, +) +from app.application.plugin.identity import ( + PluginPayloadSourceType, + TrustedPluginSourceType, + normalize_physical_plugin_id, +) +from app.application.plugin.identity_migration import ( + PluginIdentityMigrationService, + configure_plugin_identity_migration, + get_plugin_identity_migration, +) +from app.application.plugin.install import PluginInstallCommand +from app.application.plugin.inventory import PluginCandidateInventoryReader +from app.application.plugin.lifecycle import PluginStartupLease +from app.application.plugin.recovery import ( + PluginInstallationRecoveryService, + configure_plugin_installation_recovery, +) from app.application.plugin.routes import register_plugin_api +from app.application.plugin.runtime import get_plugin_manager +from app.application.plugin.transaction import ( + PluginPersistenceService, + get_plugin_persistence, +) +from app.application.scheduling import update_plugin_job from app.runtime.compat.diagnostics import ( configure_legacy_import_diagnostics, scan_plugin_legacy_imports, @@ -11,9 +42,12 @@ from app.runtime.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() from app.adapters.external.market import ( + LOCAL_REPO_PREFIX, VERSION_BACKWARD_COMPATIBLE_FLAGS, PluginHelper, configure_installed_plugins_provider, + configure_plugin_install_gateway, + split_plugin_market_repo_urls, ) from app.adapters.external.plugin.client import PluginMarketClient from app.adapters.external.server import MoviePilotServerHelper @@ -42,7 +76,6 @@ from app.runtime.extensions.plugin.system import ( from app.runtime.extensions.plugin_manager import ( PluginManager, configure_plugin_catalog_factory, - configure_plugin_install_reporter, configure_plugin_legacy_import_services, configure_plugin_resource_import_preparer, configure_plugin_route_refresher, @@ -85,12 +118,137 @@ def configure_plugin_services() -> None: """把兼容诊断、远程上报和站点认证等级装配到插件管理器。""" plugin_helper = PluginHelper() market_client = PluginMarketClient(plugin_helper) + package_manager = PluginPackageManager(plugin_helper) + plugin_manager = get_plugin_manager() + inventory_reader = PluginCandidateInventoryReader( + market_loader=market_client.get_plugin_index_result, + async_market_loader=market_client.async_get_plugin_index_result, + local_candidate_loader=market_client.get_local_candidates, + ) + persistence = get_plugin_persistence() + + async def load_inventory(force: bool): + """读取本轮配置市场和本地仓库的完整候选事实。""" + return await inventory_reader.async_load( + split_plugin_market_repo_urls(settings.PLUGIN_MARKET), + force=force, + ) + + async def reload_plugin_tree(plugin_id: str) -> object: + """在线程池中重建源插件及其全部虚拟实例。""" + return await run_in_threadpool_to_completion( + plugin_manager.reload_plugin_tree, + plugin_id, + ) + + async def refresh_plugin_registrations(plugin_id: str) -> None: + """刷新源插件及其虚拟实例的调度、命令和路由注册。""" + for target_id in plugin_manager.get_plugin_reload_targets(plugin_id): + await run_in_threadpool_to_completion( + _register_plugin_runtime, + target_id, + ) + + command = PluginInstallCommand( + persistence=persistence, + installed_plugins_reader=lambda: get_configured_system_config().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + plugin_ids_provider=plugin_manager.get_plugin_ids, + packages=package_manager, + install_reporter=lambda plugin_id, repo_url: ( + MoviePilotServerHelper.async_install_plugin_reg( + plugin_id=plugin_id, + repo_url=repo_url, + ) + ), + target_reloader=reload_plugin_tree, + rollback_reloader=reload_plugin_tree, + registration_refresher=refresh_plugin_registrations, + mutation=plugin_manager.mutation, + package_write_guard=plugin_manager.suppress_plugin_monitor, + clock=lambda: datetime.now(timezone.utc), + transaction_id_factory=lambda: uuid.uuid4().hex, + ) + gateway = PluginInstallGateway( + inventory=load_inventory, + identity=persistence.get_identity, + candidate_compatibility=lambda candidate: ( + plugin_helper.check_plugin_system_version(candidate.dto) + ), + executor=command, + clock=lambda: datetime.now(timezone.utc), + ) + configure_plugin_install_service(gateway) + configure_plugin_installation_recovery( + PluginInstallationRecoveryService( + persistence=persistence, + packages=package_manager, + ) + ) + configure_plugin_identity_migration( + PluginIdentityMigrationService( + persistence=persistence, + inventory=load_inventory, + installed_plugins=lambda: get_configured_system_config().get( + SystemConfigKey.UserInstalledPlugins + ) or [], + is_virtual_instance=lambda plugin_id: ( + plugin_manager.get_plugin_instance(plugin_id) is not None + ), + clock=lambda: datetime.now(timezone.utc), + ) + ) + + def install_from_compat_helper( + plugin_id: str, + repo_url: str, + package_version: str | None, + release_version: str | None, + force: bool, + ) -> tuple[bool, str]: + """保留本地来源定位;在线兼容参数不得升级为选源授权。""" + local_sync = bool(repo_url and repo_url.startswith(LOCAL_REPO_PREFIX)) + return _run_plugin_install_sync( + gateway, + plugin_id=plugin_id, + repo_url=repo_url if local_sync else "", + package_version=package_version, + release_version=release_version, + force=force, + local_sync=local_sync, + explicit_source=local_sync, + ) + + async def async_install_from_compat_helper( + plugin_id: str, + repo_url: str, + package_version: str | None, + release_version: str | None, + force: bool, + ) -> tuple[bool, str]: + """异步保留本地来源定位;在线兼容参数不得升级为选源授权。""" + local_sync = bool(repo_url and repo_url.startswith(LOCAL_REPO_PREFIX)) + return await _run_plugin_install_async( + gateway, + plugin_id=plugin_id, + repo_url=repo_url if local_sync else "", + package_version=package_version, + release_version=release_version, + force=force, + local_sync=local_sync, + explicit_source=local_sync, + ) + + configure_plugin_install_gateway( + install=install_from_compat_helper, + async_install=async_install_from_compat_helper, + ) configure_plugin_legacy_import_services( diagnostics_configurator=configure_legacy_import_diagnostics, import_scanner=scan_plugin_legacy_imports, ) configure_plugin_resource_import_preparer(_prepare_legacy_plugin_import) - configure_plugin_install_reporter(MoviePilotServerHelper.install_plugin_reg) configure_site_auth_level_provider(lambda: SitesHelper().auth_level) configure_installed_plugins_provider( lambda: get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] @@ -99,7 +257,7 @@ def configure_plugin_services() -> None: configure_plugin_route_refresher(register_plugin_api) configure_plugin_system(PluginSystemServices( market=market_client, - package=PluginPackageManager(plugin_helper), + package=package_manager, dependency=PluginDependencyInstaller( plugin_helper, installed_plugins_provider=lambda: get_configured_system_config().get( @@ -113,6 +271,7 @@ def configure_plugin_services() -> None: if flag else [] ), frozen=SystemUtils.is_frozen, + install=lambda **kwargs: _run_plugin_install_sync(gateway, **kwargs), )) configure_plugin_storage(PluginStorage( read=lambda key: get_configured_system_config().get(key), @@ -123,6 +282,112 @@ def configure_plugin_services() -> None: )) +def _register_plugin_runtime(plugin_id: str) -> None: + """重建一个插件的定时任务、命令和动态路由注册。""" + update_plugin_job(plugin_id) + init_commands(plugin_id) + register_plugin_api(plugin_id) + + +async def _collect_online_restore_plugins( + persistence: PluginPersistenceService, + installed_plugins: list[str], +) -> set[str]: + """找出当前载荷为本地且仍保留可信在线来源的物理插件。""" + restore_plugins: set[str] = set() + seen: set[str] = set() + for plugin_id in installed_plugins: + try: + normalized_id = normalize_physical_plugin_id(plugin_id) + except ValueError: + continue + if normalized_id in seen: + continue + seen.add(normalized_id) + identity = await persistence.get_identity(normalized_id) + if ( + identity is not None + and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN + and identity.payload_source_type is PluginPayloadSourceType.LOCAL + ): + restore_plugins.add(normalized_id) + return restore_plugins + + +async def _run_plugin_install_async( + gateway: PluginInstallGateway, + *, + plugin_id: str, + repo_url: str, + package_version: str | None, + release_version: str | None, + force: bool, + local_sync: bool, + explicit_source: bool, + startup_token: PluginStartupLease | None = None, +) -> tuple[bool, str]: + """把公开异步兼容入口转为统一 Gateway 结果。""" + try: + result = await gateway.install( + plugin_id=plugin_id, + repo_url=repo_url or None, + package_version=package_version, + release_version=release_version, + force=force, + explicit_source=explicit_source, + startup_token=startup_token, + local_sync=local_sync, + ) + return result.success, result.message + except Exception as error: # noqa: BLE001 - 公开兼容入口以结果表达失败 + logger.error("插件 %s 异步安装失败:%s", plugin_id, error) + return False, str(error) + + +def _run_plugin_install_sync( + gateway: PluginInstallGateway, + *, + plugin_id: str, + repo_url: str, + package_version: str | None, + release_version: str | None, + force: bool, + local_sync: bool, + explicit_source: bool, + startup_token: PluginStartupLease | None = None, +) -> tuple[bool, str]: + """从插件工作线程把同步兼容调用提交到宿主事件循环。""" + try: + loop = global_vars.loop + except RuntimeError: + return False, "插件安装服务当前不可用" + try: + current_loop = asyncio.get_running_loop() + except RuntimeError: + current_loop = None + if current_loop is loop: + return False, "事件循环内请使用 PluginHelper.async_install()" + future = asyncio.run_coroutine_threadsafe( + _run_plugin_install_async( + gateway, + plugin_id=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force=force, + local_sync=local_sync, + explicit_source=explicit_source, + startup_token=startup_token, + ), + loop, + ) + try: + return future.result() + except Exception as error: # noqa: BLE001 - 兼容入口以结果表达失败 + logger.error("插件 %s 同步安装失败:%s", plugin_id, error) + return False, str(error) + + def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService: """在组合根连接目录用例、市场客户端、持久化读取和插件 DTO 映射。""" client = PluginMarketClient() @@ -140,7 +405,9 @@ def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService: ) -async def sync_plugins() -> bool: +async def sync_plugins( + startup_token: PluginStartupLease | None = None, +) -> bool: """ 初始化安装插件,并动态注册后台任务及API """ @@ -150,8 +417,21 @@ async def sync_plugins() -> bool: plugin_manager = PluginManager() with plugin_manager.mutation("启动后同步插件"): configure_plugin_services() + await get_plugin_identity_migration().migrate() + installed_plugins = get_configured_system_config().get( + SystemConfigKey.UserInstalledPlugins + ) or [] + online_restore_plugins = await _collect_online_restore_plugins( + get_plugin_persistence(), + installed_plugins, + ) plugin_manager.set_plugin_settling(True) - return await _sync_plugins_admitted(plugin_manager, loop) + return await _sync_plugins_admitted( + plugin_manager, + loop, + startup_token, + online_restore_plugins, + ) except PluginMutationRejectedError as error: logger.warning(str(error)) return False @@ -160,9 +440,21 @@ async def sync_plugins() -> bool: return False -async def _sync_plugins_admitted(plugin_manager: PluginManager, loop) -> bool: +async def _sync_plugins_admitted( + plugin_manager: PluginManager, + loop, + startup_token: PluginStartupLease | None, + online_restore_plugins: set[str], +) -> bool: """在一个 admission lease 内完成包、依赖、实例和动态路由同步。""" - sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地") + sync_result = await execute_task( + loop, + lambda: plugin_manager.sync( + startup_token, + online_restore_plugins=online_restore_plugins, + ), + "插件同步到本地", + ) dependency_result = await ( plugin_manager.async_install_plugin_missing_dependencies_with_status() ) diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 738203748..0920117c4 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -8,6 +8,7 @@ from typing import Awaitable, Callable from fastapi import FastAPI +from app.application.plugin.recovery import get_plugin_installation_recovery from app.startup.initializers.cache import configure_cache_dependencies # 缓存装饰器会在业务模块导入时创建后端,必须先完成适配器装配。 @@ -96,8 +97,8 @@ async def init_extra(): return plugin_manager = get_plugin_manager() try: - async with plugin_lifecycle.hold_startup(): - if await sync_plugins(): + async with plugin_lifecycle.hold_startup() as startup_token: + if await sync_plugins(startup_token): await execute_task( global_vars.loop, init_plugin_scheduler, @@ -305,10 +306,11 @@ async def stop_task_registry(app: FastAPI) -> bool: return await task_registry.shutdown(timeout_seconds=30.0) -def prepare_plugin_restore() -> None: - """先装配插件外部系统服务,再恢复插件及其依赖。""" +async def prepare_plugin_restore() -> None: + """先恢复未完成安装事务,再加载持久插件备份及其依赖。""" configure_plugin_services() - SystemChain().restore_plugins() + await get_plugin_installation_recovery().replay() + await run_in_threadpool_to_completion(SystemChain().restore_plugins) def schedule_plugin_settlement(app: FastAPI) -> None: diff --git a/database/versions/e4f7a1b2c3d5_3_0_10.py b/database/versions/e4f7a1b2c3d5_3_0_10.py new file mode 100644 index 000000000..6d2456c59 --- /dev/null +++ b/database/versions/e4f7a1b2c3d5_3_0_10.py @@ -0,0 +1,76 @@ +"""3.0.10 add durable plugin installation transactions. + +Revision ID: e4f7a1b2c3d5 +Revises: d2e4f6a8b0c1 +Create Date: 2026-08-25 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "e4f7a1b2c3d5" +down_revision = "d2e4f6a8b0c1" +branch_labels = None +depends_on = None + + +def _id_column(dialect_name: str) -> sa.Column: + """保持 PostgreSQL Identity 与 SQLite 整数主键的当前模型语义一致。""" + if dialect_name == "postgresql": + return sa.Column( + "id", + sa.Integer(), + sa.Identity(start=1, cycle=True), + nullable=False, + ) + return sa.Column("id", sa.Integer(), nullable=False) + + +def upgrade() -> None: + """创建单插件安装事务状态存储。""" + inspector = sa.inspect(op.get_bind()) + if "plugininstallation" in inspector.get_table_names(): + return + op.create_table( + "plugininstallation", + _id_column(op.get_bind().dialect.name), + sa.Column("transaction_id", sa.String(length=128), nullable=False), + sa.Column("plugin_id", sa.String(length=128), nullable=False), + sa.Column("phase", sa.String(length=16), nullable=False), + sa.Column("membership_before", sa.Boolean(), nullable=False), + sa.Column("membership_target", sa.Boolean(), nullable=True), + sa.Column("identity_before_revision", sa.Integer(), nullable=True), + sa.Column("identity_target_revision", sa.Integer(), nullable=True), + sa.Column("package_existed", sa.Boolean(), nullable=False), + sa.Column("persistent_backup_existed", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.String(length=40), nullable=False), + sa.Column("updated_at", sa.String(length=40), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "transaction_id", + name="uq_plugininstallation_transaction_id", + ), + sa.CheckConstraint("plugin_id <> ''", name="ck_plugininstallation_plugin_id"), + sa.CheckConstraint("phase <> ''", name="ck_plugininstallation_phase"), + ) + op.create_index( + "ix_plugininstallation_plugin_id", + "plugininstallation", + ["plugin_id"], + ) + op.create_index( + "ix_plugininstallation_phase", + "plugininstallation", + ["phase"], + ) + + +def downgrade() -> None: + """删除插件安装事务状态表。""" + inspector = sa.inspect(op.get_bind()) + if "plugininstallation" not in inspector.get_table_names(): + return + op.drop_index("ix_plugininstallation_phase", table_name="plugininstallation") + op.drop_index("ix_plugininstallation_plugin_id", table_name="plugininstallation") + op.drop_table("plugininstallation") diff --git a/tests/conftest.py b/tests/conftest.py index 863601a89..61f95f151 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -314,6 +314,7 @@ def configure_plugin_system_services(): if flag else [] ), frozen=lambda: False, + install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"), )) from app.agent.skills.registry import SkillHelper from app.agent.llm.gateway import register_llm_provider_runtime diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 285a8c61f..fbad8b3ec 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6706, - "edge_sha256": "dc1249bc5f0ae05ec11c680236cf4dd389258cf883fbed66ed57b53875f9ef0e", + "edge_count": 6767, + "edge_sha256": "659804b4d1c0f3ff4d96e8c9a059df0afb74c91f368a73188c740558114efa61", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -157,6 +157,8 @@ "app.adapters.system.plugin.package -> app.adapters", "app.adapters.system.plugin.package -> app.adapters.external", "app.adapters.system.plugin.package -> app.adapters.external.market", + "app.adapters.system.plugin.package -> app.adapters.system", + "app.adapters.system.plugin.package -> app.adapters.system.host", "app.adapters.system.plugin.package -> app.runtime", "app.adapters.system.plugin.package -> app.runtime.execution", "app.adapters.system.plugin.package -> app.runtime.log", @@ -590,10 +592,6 @@ "app.agent.tools.impl._plugin_tool_utils -> app.adapters", "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external", "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.market", - "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.server", - "app.agent.tools.impl._plugin_tool_utils -> app.adapters.system", - "app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin", - "app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin.package", "app.agent.tools.impl._plugin_tool_utils -> app.agent", "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools", "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools.base", @@ -602,7 +600,7 @@ "app.agent.tools.impl._plugin_tool_utils -> app.application.configuration", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.folders", - "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install", + "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.gateway", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.routes", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime", "app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling", @@ -2100,9 +2098,6 @@ "app.api.endpoints.plugin -> app.adapters.external", "app.api.endpoints.plugin -> app.adapters.external.market", "app.api.endpoints.plugin -> app.adapters.external.server", - "app.api.endpoints.plugin -> app.adapters.system", - "app.api.endpoints.plugin -> app.adapters.system.plugin", - "app.api.endpoints.plugin -> app.adapters.system.plugin.package", "app.api.endpoints.plugin -> app.adapters.web", "app.api.endpoints.plugin -> app.adapters.web.security", "app.api.endpoints.plugin -> app.adapters.web.security.access", @@ -2119,13 +2114,13 @@ "app.api.endpoints.plugin -> app.application.plugin", "app.api.endpoints.plugin -> app.application.plugin.config", "app.api.endpoints.plugin -> app.application.plugin.folders", - "app.api.endpoints.plugin -> app.application.plugin.install", + "app.api.endpoints.plugin -> app.application.plugin.gateway", "app.api.endpoints.plugin -> app.application.plugin.routes", "app.api.endpoints.plugin -> app.application.plugin.runtime", + "app.api.endpoints.plugin -> app.application.plugin.transaction", "app.api.endpoints.plugin -> app.application.scheduling", "app.api.endpoints.plugin -> app.runtime", "app.api.endpoints.plugin -> app.runtime.cache", - "app.api.endpoints.plugin -> app.runtime.execution", "app.api.endpoints.plugin -> app.runtime.extensions", "app.api.endpoints.plugin -> app.runtime.extensions.plugin", "app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts", @@ -2747,6 +2742,11 @@ "app.application.notification -> app.schemas.types", "app.application.outbox -> app.schemas", "app.application.outbox -> app.schemas.types", + "app.application.plugin.admission -> app.application", + "app.application.plugin.admission -> app.application.plugin", + "app.application.plugin.admission -> app.application.plugin.identity", + "app.application.plugin.admission -> app.application.plugin.inventory", + "app.application.plugin.admission -> app.application.plugin.source", "app.application.plugin.config -> app.schemas", "app.application.plugin.config -> app.schemas.exception", "app.application.plugin.folders -> app.application", @@ -2755,16 +2755,52 @@ "app.application.plugin.folders -> app.runtime.log", "app.application.plugin.folders -> app.schemas", "app.application.plugin.folders -> app.schemas.types", + "app.application.plugin.gateway -> app.application", + "app.application.plugin.gateway -> app.application.plugin", + "app.application.plugin.gateway -> app.application.plugin.admission", + "app.application.plugin.gateway -> app.application.plugin.identity", + "app.application.plugin.gateway -> app.application.plugin.install", + "app.application.plugin.gateway -> app.application.plugin.inventory", + "app.application.plugin.gateway -> app.application.plugin.lifecycle", + "app.application.plugin.gateway -> app.application.plugin.source", + "app.application.plugin.identity_migration -> app.application", + "app.application.plugin.identity_migration -> app.application.plugin", + "app.application.plugin.identity_migration -> app.application.plugin.identity", + "app.application.plugin.identity_migration -> app.application.plugin.source", + "app.application.plugin.identity_migration -> app.runtime", + "app.application.plugin.identity_migration -> app.runtime.log", "app.application.plugin.install -> app.application", "app.application.plugin.install -> app.application.plugin", - "app.application.plugin.install -> app.application.plugin.lifecycle", + "app.application.plugin.install -> app.application.plugin.admission", + "app.application.plugin.install -> app.application.plugin.identity", + "app.application.plugin.install -> app.application.plugin.source", + "app.application.plugin.install -> app.application.plugin.transaction", "app.application.plugin.install -> app.runtime", "app.application.plugin.install -> app.runtime.execution", "app.application.plugin.install -> app.runtime.log", "app.application.plugin.install -> app.schemas", "app.application.plugin.install -> app.schemas.exception", + "app.application.plugin.inventory -> app.application", + "app.application.plugin.inventory -> app.application.plugin", + "app.application.plugin.inventory -> app.application.plugin.identity", + "app.application.plugin.inventory -> app.application.plugin.source", + "app.application.plugin.recovery -> app.application", + "app.application.plugin.recovery -> app.application.plugin", + "app.application.plugin.recovery -> app.application.plugin.install", + "app.application.plugin.recovery -> app.application.plugin.transaction", + "app.application.plugin.recovery -> app.runtime", + "app.application.plugin.recovery -> app.runtime.log", "app.application.plugin.runtime -> app.schemas", "app.application.plugin.runtime -> app.schemas.types", + "app.application.plugin.source -> app.application", + "app.application.plugin.source -> app.application.plugin", + "app.application.plugin.source -> app.application.plugin.identity", + "app.application.plugin.source -> app.foundation", + "app.application.plugin.source -> app.foundation.version", + "app.application.plugin.transaction -> app.application", + "app.application.plugin.transaction -> app.application.database", + "app.application.plugin.transaction -> app.application.plugin", + "app.application.plugin.transaction -> app.application.plugin.identity", "app.application.recognition -> app.application", "app.application.recognition -> app.application.configuration", "app.application.recognition -> app.schemas", @@ -3637,6 +3673,14 @@ "app.db.adapters.pluginidentity -> app.db.oper", "app.db.adapters.pluginidentity -> app.db.oper.pluginidentity", "app.db.adapters.pluginidentity -> app.db.uow", + "app.db.adapters.plugininstallation -> app.application", + "app.db.adapters.plugininstallation -> app.application.plugin", + "app.db.adapters.plugininstallation -> app.application.plugin.identity", + "app.db.adapters.plugininstallation -> app.application.plugin.transaction", + "app.db.adapters.plugininstallation -> app.db", + "app.db.adapters.plugininstallation -> app.db.models", + "app.db.adapters.plugininstallation -> app.db.models.pluginidentity", + "app.db.adapters.plugininstallation -> app.db.models.plugininstallation", "app.db.adapters.site -> app.db", "app.db.adapters.site -> app.db.oper", "app.db.adapters.site -> app.db.oper.site", @@ -3727,6 +3771,8 @@ "app.db.models.plugindata -> app.db.base", "app.db.models.pluginidentity -> app.db", "app.db.models.pluginidentity -> app.db.base", + "app.db.models.plugininstallation -> app.db", + "app.db.models.plugininstallation -> app.db.base", "app.db.models.site -> app.db", "app.db.models.site -> app.db.base", "app.db.models.siteicon -> app.db", @@ -6344,6 +6390,7 @@ "app.startup.initializers.modules -> app.application.outbox", "app.startup.initializers.modules -> app.application.plugin", "app.startup.initializers.modules -> app.application.plugin.runtime", + "app.startup.initializers.modules -> app.application.plugin.transaction", "app.startup.initializers.modules -> app.application.security", "app.startup.initializers.modules -> app.application.security.auth", "app.startup.initializers.modules -> app.application.security.passkeys", @@ -6375,6 +6422,8 @@ "app.startup.initializers.modules -> app.db.adapters.chain", "app.startup.initializers.modules -> app.db.adapters.download", "app.startup.initializers.modules -> app.db.adapters.outbox", + "app.startup.initializers.modules -> app.db.adapters.pluginidentity", + "app.startup.initializers.modules -> app.db.adapters.plugininstallation", "app.startup.initializers.modules -> app.db.adapters.site", "app.startup.initializers.modules -> app.db.adapters.subscription", "app.startup.initializers.modules -> app.db.adapters.transaction", @@ -6446,11 +6495,22 @@ "app.startup.initializers.plugins -> app.adapters.system.plugin.manifest", "app.startup.initializers.plugins -> app.adapters.system.plugin.package", "app.startup.initializers.plugins -> app.application", + "app.startup.initializers.plugins -> app.application.commands", "app.startup.initializers.plugins -> app.application.configuration", "app.startup.initializers.plugins -> app.application.plugin", "app.startup.initializers.plugins -> app.application.plugin.catalog", "app.startup.initializers.plugins -> app.application.plugin.data", + "app.startup.initializers.plugins -> app.application.plugin.gateway", + "app.startup.initializers.plugins -> app.application.plugin.identity", + "app.startup.initializers.plugins -> app.application.plugin.identity_migration", + "app.startup.initializers.plugins -> app.application.plugin.install", + "app.startup.initializers.plugins -> app.application.plugin.inventory", + "app.startup.initializers.plugins -> app.application.plugin.lifecycle", + "app.startup.initializers.plugins -> app.application.plugin.recovery", "app.startup.initializers.plugins -> app.application.plugin.routes", + "app.startup.initializers.plugins -> app.application.plugin.runtime", + "app.startup.initializers.plugins -> app.application.plugin.transaction", + "app.startup.initializers.plugins -> app.application.scheduling", "app.startup.initializers.plugins -> app.application.site", "app.startup.initializers.plugins -> app.db", "app.startup.initializers.plugins -> app.db.oper", @@ -6498,6 +6558,7 @@ "app.startup.lifecycle -> app.application", "app.startup.lifecycle -> app.application.plugin", "app.startup.lifecycle -> app.application.plugin.lifecycle", + "app.startup.lifecycle -> app.application.plugin.recovery", "app.startup.lifecycle -> app.application.plugin.runtime", "app.startup.lifecycle -> app.chain", "app.startup.lifecycle -> app.chain.system", @@ -6723,7 +6784,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 824, + "module_count": 833, "modules": [ "app", "app.adapters", @@ -7014,15 +7075,22 @@ "app.application.notification", "app.application.outbox", "app.application.plugin", + "app.application.plugin.admission", "app.application.plugin.catalog", "app.application.plugin.config", "app.application.plugin.data", "app.application.plugin.folders", + "app.application.plugin.gateway", "app.application.plugin.identity", + "app.application.plugin.identity_migration", "app.application.plugin.install", + "app.application.plugin.inventory", "app.application.plugin.lifecycle", + "app.application.plugin.recovery", "app.application.plugin.routes", "app.application.plugin.runtime", + "app.application.plugin.source", + "app.application.plugin.transaction", "app.application.recognition", "app.application.rss", "app.application.rules", @@ -7109,6 +7177,7 @@ "app.db.adapters.download", "app.db.adapters.outbox", "app.db.adapters.pluginidentity", + "app.db.adapters.plugininstallation", "app.db.adapters.site", "app.db.adapters.subscription", "app.db.adapters.transaction", @@ -7133,6 +7202,7 @@ "app.db.models.passkey", "app.db.models.plugindata", "app.db.models.pluginidentity", + "app.db.models.plugininstallation", "app.db.models.site", "app.db.models.siteicon", "app.db.models.sitestatistic", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 872205400..52c8f6b14 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -1,41 +1,41 @@ { "schema_version": 2, - "generated_at": "2026-08-24T23:06:26.288074+00:00", - "platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O", - "python": "3.14.3", + "generated_at": "2026-08-25T23:30:44.529042+00:00", + "platform": "macOS-26.4.1-arm64-arm-64bit-Mach-O", + "python": "3.14.7", "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 364, - "max_ms": 904.069, - "median_ms": 898.164, - "min_ms": 896.39, + "loaded_app_module_count": 378, + "max_ms": 909.62, + "median_ms": 908.975, + "min_ms": 904.929, "samples_ms": [ - 904.069, - 896.39, - 898.164 + 909.62, + 904.929, + 908.975 ] }, "app.factory": { - "loaded_app_module_count": 376, - "max_ms": 923.165, - "median_ms": 921.768, - "min_ms": 921.249, + "loaded_app_module_count": 390, + "max_ms": 952.709, + "median_ms": 934.785, + "min_ms": 916.888, "samples_ms": [ - 921.768, - 923.165, - 921.249 + 952.709, + 934.785, + 916.888 ] }, "app.main": { - "loaded_app_module_count": 378, - "max_ms": 1069.392, - "median_ms": 1037.036, - "min_ms": 1027.603, + "loaded_app_module_count": 392, + "max_ms": 947.928, + "median_ms": 938.251, + "min_ms": 932.597, "samples_ms": [ - 1069.392, - 1037.036, - 1027.603 + 938.251, + 932.597, + 947.928 ] } }, @@ -47,56 +47,25 @@ { "mode": "normal", "enabled_component_count": 25, - "startup_ms": 0.645, - "full_lifespan_ms": 1.493, + "startup_ms": 0.581, + "full_lifespan_ms": 1.397, "stage_ms": { - "后台任务登记器": 0.071, - "数据库准备": 0.043, - "HTTP 基础能力": 0.029, - "领域依赖装配": 0.029, - "数据库引擎预热": 0.024, - "数据库连接预算": 0.023, - "路由": 0.022, - "模块服务": 0.024, - "插件备份恢复": 0.025, - "插件": 0.021, - "定时器": 0.025, - "监控器": 0.021, - "待处理整理回放": 0.027, - "命令服务": 0.024, - "工作流": 0.021, - "插件同步与启动收尾": 0.021 - }, - "threads_before": 2, - "threads_started": 2, - "threads_after": 2, - "tasks_before": 1, - "tasks_started": 1, - "tasks_after": 1, - "database_connections_started": 0 - }, - { - "mode": "normal", - "enabled_component_count": 25, - "startup_ms": 0.644, - "full_lifespan_ms": 1.462, - "stage_ms": { - "后台任务登记器": 0.076, - "数据库准备": 0.04, - "HTTP 基础能力": 0.03, - "领域依赖装配": 0.028, - "数据库引擎预热": 0.025, - "数据库连接预算": 0.023, - "路由": 0.024, + "后台任务登记器": 0.062, + "数据库准备": 0.036, + "HTTP 基础能力": 0.032, + "领域依赖装配": 0.032, + "数据库引擎预热": 0.028, + "数据库连接预算": 0.026, + "路由": 0.028, "模块服务": 0.023, "插件备份恢复": 0.024, - "插件": 0.02, - "定时器": 0.022, + "插件": 0.024, + "定时器": 0.025, "监控器": 0.024, - "待处理整理回放": 0.02, + "待处理整理回放": 0.025, "命令服务": 0.024, - "工作流": 0.024, - "插件同步与启动收尾": 0.023 + "工作流": 0.025, + "插件同步与启动收尾": 0.025 }, "threads_before": 2, "threads_started": 2, @@ -109,25 +78,56 @@ { "mode": "normal", "enabled_component_count": 25, - "startup_ms": 0.637, - "full_lifespan_ms": 1.493, + "startup_ms": 0.596, + "full_lifespan_ms": 1.431, "stage_ms": { - "后台任务登记器": 0.077, - "数据库准备": 0.038, - "HTTP 基础能力": 0.03, - "领域依赖装配": 0.028, - "数据库引擎预热": 0.024, - "数据库连接预算": 0.025, - "路由": 0.025, + "后台任务登记器": 0.062, + "数据库准备": 0.036, + "HTTP 基础能力": 0.033, + "领域依赖装配": 0.036, + "数据库引擎预热": 0.03, + "数据库连接预算": 0.027, + "路由": 0.029, "模块服务": 0.025, "插件备份恢复": 0.023, - "插件": 0.02, - "定时器": 0.024, - "监控器": 0.022, - "待处理整理回放": 0.022, + "插件": 0.024, + "定时器": 0.029, + "监控器": 0.024, + "待处理整理回放": 0.024, "命令服务": 0.024, "工作流": 0.02, - "插件同步与启动收尾": 0.023 + "插件同步与启动收尾": 0.025 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 1, + "tasks_after": 1, + "database_connections_started": 0 + }, + { + "mode": "normal", + "enabled_component_count": 25, + "startup_ms": 0.6, + "full_lifespan_ms": 1.435, + "stage_ms": { + "后台任务登记器": 0.067, + "数据库准备": 0.04, + "HTTP 基础能力": 0.033, + "领域依赖装配": 0.031, + "数据库引擎预热": 0.031, + "数据库连接预算": 0.028, + "路由": 0.03, + "模块服务": 0.024, + "插件备份恢复": 0.026, + "插件": 0.025, + "定时器": 0.025, + "监控器": 0.025, + "待处理整理回放": 0.02, + "命令服务": 0.025, + "工作流": 0.023, + "插件同步与启动收尾": 0.024 }, "threads_before": 2, "threads_started": 2, @@ -138,8 +138,8 @@ "database_connections_started": 0 } ], - "median_startup_ms": 0.644, - "median_full_lifespan_ms": 1.493, + "median_startup_ms": 0.596, + "median_full_lifespan_ms": 1.431, "enabled_component_count": 25, "enabled_components": [ "后台任务登记器", @@ -174,42 +174,18 @@ { "mode": "safe", "enabled_component_count": 13, - "startup_ms": 0.478, - "full_lifespan_ms": 0.888, + "startup_ms": 0.427, + "full_lifespan_ms": 0.896, "stage_ms": { - "后台任务登记器": 0.072, - "数据库准备": 0.042, - "HTTP 基础能力": 0.031, - "领域依赖装配": 0.033, - "数据库引擎预热": 0.027, - "数据库连接预算": 0.024, - "路由": 0.023, - "模块服务": 0.025, - "插件同步与启动收尾": 0.025 - }, - "threads_before": 2, - "threads_started": 2, - "threads_after": 2, - "tasks_before": 1, - "tasks_started": 1, - "tasks_after": 1, - "database_connections_started": 0 - }, - { - "mode": "safe", - "enabled_component_count": 13, - "startup_ms": 0.497, - "full_lifespan_ms": 0.913, - "stage_ms": { - "后台任务登记器": 0.078, + "后台任务登记器": 0.067, "数据库准备": 0.038, - "HTTP 基础能力": 0.03, - "领域依赖装配": 0.032, - "数据库引擎预热": 0.027, + "HTTP 基础能力": 0.032, + "领域依赖装配": 0.033, + "数据库引擎预热": 0.029, "数据库连接预算": 0.026, - "路由": 0.026, - "模块服务": 0.025, - "插件同步与启动收尾": 0.022 + "路由": 0.03, + "模块服务": 0.03, + "插件同步与启动收尾": 0.027 }, "threads_before": 2, "threads_started": 2, @@ -222,18 +198,42 @@ { "mode": "safe", "enabled_component_count": 13, - "startup_ms": 0.537, - "full_lifespan_ms": 0.962, + "startup_ms": 0.417, + "full_lifespan_ms": 0.832, "stage_ms": { - "后台任务登记器": 0.084, - "数据库准备": 0.039, - "HTTP 基础能力": 0.031, - "领域依赖装配": 0.028, - "数据库引擎预热": 0.025, - "数据库连接预算": 0.024, - "路由": 0.025, - "模块服务": 0.026, - "插件同步与启动收尾": 0.024 + "后台任务登记器": 0.067, + "数据库准备": 0.035, + "HTTP 基础能力": 0.032, + "领域依赖装配": 0.031, + "数据库引擎预热": 0.032, + "数据库连接预算": 0.027, + "路由": 0.027, + "模块服务": 0.027, + "插件同步与启动收尾": 0.026 + }, + "threads_before": 2, + "threads_started": 2, + "threads_after": 2, + "tasks_before": 1, + "tasks_started": 1, + "tasks_after": 1, + "database_connections_started": 0 + }, + { + "mode": "safe", + "enabled_component_count": 13, + "startup_ms": 0.409, + "full_lifespan_ms": 0.822, + "stage_ms": { + "后台任务登记器": 0.067, + "数据库准备": 0.034, + "HTTP 基础能力": 0.03, + "领域依赖装配": 0.029, + "数据库引擎预热": 0.028, + "数据库连接预算": 0.026, + "路由": 0.03, + "模块服务": 0.027, + "插件同步与启动收尾": 0.022 }, "threads_before": 2, "threads_started": 2, @@ -244,8 +244,8 @@ "database_connections_started": 0 } ], - "median_startup_ms": 0.497, - "median_full_lifespan_ms": 0.913, + "median_startup_ms": 0.417, + "median_full_lifespan_ms": 0.832, "enabled_component_count": 13, "enabled_components": [ "后台任务登记器", diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index 2581dbdf0..912724a48 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -10,7 +10,7 @@ from app.agent.tools.impl._plugin_tool_utils import ( install_plugin_runtime, uninstall_plugin_runtime, ) -from app.agent.tools.impl.install_plugin import InstallPluginTool +from app.agent.tools.impl.install_plugin import InstallPluginInput, InstallPluginTool from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool @@ -282,49 +282,84 @@ def test_install_plugin_installs_market_candidate() -> None: assert payload["success"] assert payload["plugin"]["id"] == "DemoPlugin" install_runtime.assert_awaited_once_with( - "DemoPlugin", "https://example.com/market", force=False + "DemoPlugin", + None, + force=False, + explicit_source=False, ) -def test_install_plugin_runtime_reloads_in_threadpool() -> None: - """ - 已存在插件刷新加载时会通过插件线程池执行重载。 - """ - plugin_manager = MagicMock() - plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"] - plugin_helper = MagicMock() - config_oper = MagicMock() - config_oper.get.return_value = ["DemoPlugin"] - calls = [] - - async def fake_run_agent_blocking(bucket, func, *args, **kwargs) -> None: - calls.append((bucket, func, args, kwargs)) - return None +def test_install_plugin_reports_source_conflict_before_retry() -> None: + """Agent 普通安装遇到多来源时返回候选,等待管理员明确选择。""" + tool = InstallPluginTool(session_id="session-1", user_id="10001") + candidate = _market_plugin("DemoPlugin", "Demo Plugin") + source_candidates = [ + { + "plugin_id": "DemoPlugin", + "source_type": "official", + "source_key": "github:jxxghp/moviepilot-plugins", + "repo_url": "https://github.com/jxxghp/MoviePilot-Plugins", + "package_generation": "v3", + "plugin_version": "1.0.0", + }, + { + "plugin_id": "DemoPlugin", + "source_type": "third_party", + "source_key": "github:example/plugins", + "repo_url": "https://github.com/example/plugins", + "package_generation": "v3", + "plugin_version": "2.0.0", + }, + ] with ( patch( - "app.agent.tools.impl._plugin_tool_utils.get_configured_system_config", - return_value=config_oper, + "app.agent.tools.impl.install_plugin.load_market_plugins", + new=AsyncMock(return_value=[candidate]), ), patch( - "app.agent.tools.impl._plugin_tool_utils.get_plugin_manager", - return_value=plugin_manager, + "app.agent.tools.impl.install_plugin.install_plugin_runtime", + new=AsyncMock(return_value=(False, "未安装插件存在多个在线来源", False)), ), patch( - "app.agent.tools.impl._plugin_tool_utils.PluginHelper", - return_value=plugin_helper, - ), - patch( - "app.agent.tools.impl._plugin_tool_utils.refresh_plugin_registrations", - ) as refresh_registrations, - patch( - "app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg", - AsyncMock(return_value=True), - ) as install_reg, - patch( - "app.agent.tools.base.run_agent_blocking", - side_effect=fake_run_agent_blocking, + "app.agent.tools.impl.install_plugin.inspect_plugin_sources", + new=AsyncMock(return_value={ + "selection_status": "conflict", + "selection_reason": "未安装插件存在多个在线来源,不能静默选择", + "inventory_complete": True, + "candidates": source_candidates, + }), ), + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + + payload = json.loads(result) + assert payload["success"] is False + assert payload["requires_explicit_source"] is True + assert payload["source_candidates"] == source_candidates + + +@pytest.mark.parametrize("repo_url", ["", " ", "local://DemoPlugin"]) +def test_install_plugin_rejects_invalid_explicit_source(repo_url: str) -> None: + """Agent 不能用空值或本地标识伪造管理员在线选源。""" + with pytest.raises(ValueError): + InstallPluginInput(plugin_id="DemoPlugin", repo_url=repo_url) + + +def test_install_plugin_runtime_uses_application_gateway() -> None: + """Agent 安装入口只能转发到统一的应用层安装 Gateway。""" + gateway = MagicMock() + gateway.install = AsyncMock( + return_value=SimpleNamespace( + success=True, + message="插件已存在,已刷新加载", + refreshed_only=True, + ) + ) + + with patch( + "app.agent.tools.impl._plugin_tool_utils.get_plugin_install_service", + return_value=gateway, ): success, message, refreshed_only = asyncio.run( install_plugin_runtime( @@ -337,18 +372,12 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None: assert success assert message == "插件已存在,已刷新加载" assert refreshed_only - install_reg.assert_awaited_once_with( + gateway.install.assert_awaited_once_with( plugin_id="DemoPlugin", repo_url="https://example.com/market", + force=False, + explicit_source=False, ) - assert len(calls) == 2 - assert calls[0][0] == "plugin" - assert calls[0][2] == (plugin_manager.reload_plugin_tree, "DemoPlugin") - assert calls[0][3] == {} - assert calls[1][0] == "plugin" - assert calls[1][1] == refresh_registrations - assert calls[1][2] == ("DemoPlugin",) - assert calls[1][3] == {} def test_uninstall_plugin_uninstalls_installed_candidate() -> None: diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index a114eb4f8..dcd1dceee 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -45,6 +45,13 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict: ): monkeypatch.setattr(lifecycle, name, MagicMock()) monkeypatch.setattr(lifecycle, "configure_plugin_services", MagicMock()) + plugin_recovery = MagicMock() + plugin_recovery.replay = AsyncMock() + monkeypatch.setattr( + lifecycle, + "get_plugin_installation_recovery", + MagicMock(return_value=plugin_recovery), + ) monkeypatch.setattr(lifecycle, "init_modules", AsyncMock()) # 启动期的引擎预热与额度核算也要打桩。不打的话这些用例会走真实的引擎创建,在测试 @@ -442,6 +449,9 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch): shutdown_steps = _patch_lifespan(monkeypatch) order = [] lifecycle.configure_plugin_services.side_effect = lambda: order.append("configure") + lifecycle.get_plugin_installation_recovery.return_value.replay.side_effect = ( + lambda: order.append("replay") + ) lifecycle.SystemChain.return_value.restore_plugins.side_effect = ( lambda: order.append("restore") ) @@ -452,7 +462,7 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch): asyncio.run(run_lifespan()) - assert order == ["configure", "restore"] + assert order == ["configure", "replay", "restore"] _assert_completed_once(shutdown_steps["close_http"]) diff --git a/tests/test_plugin_candidate_inventory.py b/tests/test_plugin_candidate_inventory.py new file mode 100644 index 000000000..d7816f10c --- /dev/null +++ b/tests/test_plugin_candidate_inventory.py @@ -0,0 +1,290 @@ +"""插件市场候选库存读取测试。""" + +import pytest + +from app.application.plugin.identity import TrustedPluginSourceType +from app.application.plugin.inventory import PluginCandidateInventoryReader +from app.application.plugin.source import LocalCandidateReadStatus, MarketReadStatus + +OFFICIAL_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins" +THIRD_PARTY_MARKET = "https://github.com/example/moviepilot-plugins" + + +def test_load_reads_each_market_in_v3_v2_v1_order_and_keeps_all_facts() -> None: + """每个市场的三代索引都应有独立读取记录,且同 ID 候选不能被合并。""" + calls: list[tuple[str, str | None, bool]] = [] + + def loader(market: str, package_version: str | None, force: bool): + calls.append((market, package_version, force)) + return { + "DemoPlugin": { + "version": f"{package_version or '1'}.0.0", + "v3": True, + }, + } + + inventory = PluginCandidateInventoryReader(market_loader=loader).load( + [OFFICIAL_MARKET, THIRD_PARTY_MARKET], + force=True, + ) + + assert calls == [ + (OFFICIAL_MARKET, "v3", True), + (OFFICIAL_MARKET, "v2", True), + (OFFICIAL_MARKET, None, True), + (THIRD_PARTY_MARKET, "v3", True), + (THIRD_PARTY_MARKET, "v2", True), + (THIRD_PARTY_MARKET, None, True), + ] + assert inventory.complete + assert [(read.market, read.package_generation) for read in inventory.market_reads] == [ + (OFFICIAL_MARKET, "v3"), + (OFFICIAL_MARKET, "v2"), + (OFFICIAL_MARKET, "v1"), + (THIRD_PARTY_MARKET, "v3"), + (THIRD_PARTY_MARKET, "v2"), + (THIRD_PARTY_MARKET, "v1"), + ] + assert len(inventory.candidates_for("demoplugin")) == 6 + + +def test_only_v3_compatible_entries_are_candidates() -> None: + """V3 明确排除项以及 V1 未声明兼容项不能进入候选库存。""" + def loader(_market: str, package_version: str | None, _force: bool): + if package_version == "v3": + return { + "V3Plugin": {"version": "3.0.0"}, + "ExcludedPlugin": {"version": "3.0.0", "v3": False}, + } + if package_version == "v2": + return { + "SharedPlugin": {"version": "2.0.0"}, + "ExcludedPlugin": {"version": "2.0.0", "v3": False}, + } + return { + "DeclaredV3": {"version": "1.0.0", "v3": True}, + "DeclaredV2": {"version": "1.0.0", "v2": True}, + "Undeclared": {"version": "1.0.0"}, + "ExcludedPlugin": {"version": "1.0.0", "v3": False, "v2": True}, + } + + inventory = PluginCandidateInventoryReader(market_loader=loader).load( + [THIRD_PARTY_MARKET] + ) + + assert { + candidate.plugin_id + for candidate in inventory.online_candidates + } == {"V3Plugin", "SharedPlugin", "DeclaredV3", "DeclaredV2"} + assert not inventory.candidates_for("ExcludedPlugin") + assert not inventory.candidates_for("Undeclared") + + +def test_official_source_is_classified_and_public_candidate_uses_plugin_version() -> None: + """官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。""" + reader = PluginCandidateInventoryReader( + market_loader=lambda *_args: {"DemoPlugin": {"version": "3.1.0"}}, + ) + + candidate = reader.load([OFFICIAL_MARKET]).online_candidates[0] + + assert candidate.source_key == "github:jxxghp/moviepilot-plugins" + assert candidate.source_type is TrustedPluginSourceType.OFFICIAL + assert candidate.plugin_version == "3.1.0" + assert candidate.public_dict() == { + "plugin_id": "DemoPlugin", + "source_key": "github:jxxghp/moviepilot-plugins", + "source_type": "official", + "repo_url": "https://github.com/jxxghp/MoviePilot-Plugins", + "package_generation": "v3", + "plugin_version": "3.1.0", + } + + +def test_partial_generation_failure_blocks_tofu_but_keeps_successful_candidates() -> None: + """某一代读取失败时保留其他代候选,但库存不能用于第三方 TOFU。""" + def loader(_market: str, package_version: str | None, _force: bool): + if package_version == "v2": + raise TimeoutError("timeout") + return {"DemoPlugin": {"version": "3.0.0", "v3": True}} + + inventory = PluginCandidateInventoryReader(market_loader=loader).load( + [THIRD_PARTY_MARKET] + ) + + assert len(inventory.candidates_for("DemoPlugin")) == 2 + assert not inventory.complete + assert not inventory.can_use_for_tofu + assert inventory.read_for(THIRD_PARTY_MARKET, "v2") is not None + assert inventory.read_for(THIRD_PARTY_MARKET, "v2").error + + +def test_absent_generation_is_complete_without_creating_candidates() -> None: + """确定不存在的代际索引属于完整库存,不应被误判为网络失败。""" + + def loader(_market: str, package_version: str | None, _force: bool): + if package_version == "v2": + return None + return {"DemoPlugin": {"version": "3.0.0", "v3": True}} + + inventory = PluginCandidateInventoryReader(market_loader=loader).load( + [THIRD_PARTY_MARKET] + ) + absent = inventory.read_for(THIRD_PARTY_MARKET, "v2") + + assert absent is not None + assert absent.status is MarketReadStatus.ABSENT + assert absent.candidates == () + assert inventory.complete + assert inventory.can_use_for_tofu + assert len(inventory.candidates_for("DemoPlugin")) == 2 + + +def test_empty_index_is_present_and_complete() -> None: + """真实存在但为空的索引与 absent 保持可观察差异。""" + inventory = PluginCandidateInventoryReader( + market_loader=lambda *_args: {}, + ).load([THIRD_PARTY_MARKET]) + + assert inventory.complete + assert all( + read.status is MarketReadStatus.PRESENT + for read in inventory.market_reads + ) + assert inventory.online_candidates == () + + +def test_loader_exception_blocks_tofu() -> None: + """Adapter 读取失败时必须阻止唯一第三方来源 TOFU。""" + + def loader(_market: str, package_version: str | None, _force: bool): + if package_version == "v2": + raise TimeoutError("timeout") + return {"DemoPlugin": {"version": "3.0.0", "v3": True}} + + inventory = PluginCandidateInventoryReader(market_loader=loader).load( + [THIRD_PARTY_MARKET] + ) + + assert not inventory.complete + assert not inventory.can_use_for_tofu + assert inventory.read_for(THIRD_PARTY_MARKET, "v2").status is MarketReadStatus.FAILED + + +def test_local_scan_preserves_absent_present_and_failed_states() -> None: + """本地仓库扫描不能把未配置、空扫描和异常读取混为一谈。""" + def market_loader(*_args): + return {} + + absent = PluginCandidateInventoryReader(market_loader=market_loader).load( + [THIRD_PARTY_MARKET] + ) + present = PluginCandidateInventoryReader( + market_loader=market_loader, + local_candidate_loader=lambda: {}, + ).load([THIRD_PARTY_MARKET]) + + def failed_loader(): + raise OSError("local repository unavailable") + + failed = PluginCandidateInventoryReader( + market_loader=market_loader, + local_candidate_loader=failed_loader, + ).load([THIRD_PARTY_MARKET]) + + assert absent.local_read.status is LocalCandidateReadStatus.ABSENT + assert present.local_read.status is LocalCandidateReadStatus.PRESENT + assert present.local_read.candidates == () + assert failed.local_read.status is LocalCandidateReadStatus.FAILED + assert failed.local_read.error == "local repository unavailable" + + +def test_local_candidates_never_expose_path_in_inventory_projection() -> None: + """本地候选可参与库存,但公共投影永不携带本地仓库路径。""" + reader = PluginCandidateInventoryReader( + market_loader=lambda *_args: {}, + local_candidate_loader=lambda: { + "LocalPlugin": { + "version": "3.0.0", + "package_version": "v3", + "repo_url": "local://LocalPlugin?path=/private/local&version=v3", + "path": "/private/local/plugins/LocalPlugin", + "repo_path": "/private/local", + }, + }, + ) + + inventory = reader.load([OFFICIAL_MARKET]) + public = inventory.public_dict() + + assert inventory.local_candidates[0].plugin_id == "LocalPlugin" + assert public["local_candidates"] == [{ + "plugin_id": "LocalPlugin", + "source_type": "local", + "package_generation": "v3", + "plugin_version": "3.0.0", + }] + assert "/private/local" not in str(public) + + +def test_invalid_local_candidate_does_not_abort_online_inventory() -> None: + """本地索引中的坏代际条目应被跳过,不能丢失在线库存。""" + reader = PluginCandidateInventoryReader( + market_loader=lambda *_args: { + "OnlinePlugin": {"version": "3.0.0"}, + }, + local_candidate_loader=lambda: { + "BrokenLocal": { + "version": "1.0.0", + "package_version": "v9", + }, + }, + ) + + inventory = reader.load([OFFICIAL_MARKET]) + + assert [candidate.plugin_id for candidate in inventory.online_candidates] == [ + "OnlinePlugin", + "OnlinePlugin", + ] + assert inventory.local_candidates == () + + +def test_invalid_market_is_recorded_for_each_generation_without_network_call() -> None: + """非法市场配置应形成三条失败事实,且不会调用市场读取端口。""" + calls: list[object] = [] + + def read(*_args): + calls.append(True) + return {} + + inventory = PluginCandidateInventoryReader(market_loader=read).load( + ["https://example.com/not-github"] + ) + + assert calls == [] + assert len(inventory.market_reads) == 3 + assert all(not read.succeeded for read in inventory.market_reads) + assert not inventory.complete + + +@pytest.mark.asyncio +async def test_async_loader_preserves_generation_facts() -> None: + """异步读取端口与同步端口拥有相同的市场代际快照合同。""" + calls: list[str | None] = [] + + async def loader(_market: str, package_version: str | None, _force: bool): + calls.append(package_version) + return {"DemoPlugin": {"version": "3.0.0", "v3": True}} + + reader = PluginCandidateInventoryReader( + market_loader=lambda *_args: {}, + async_market_loader=loader, + ) + inventory = await reader.async_load([THIRD_PARTY_MARKET]) + + assert calls == ["v3", "v2", None] + assert inventory.complete + assert [read.package_generation for read in inventory.market_reads] == [ + "v3", "v2", "v1" + ] diff --git a/tests/test_plugin_external_install_boundary.py b/tests/test_plugin_external_install_boundary.py new file mode 100644 index 000000000..dd2b98f7c --- /dev/null +++ b/tests/test_plugin_external_install_boundary.py @@ -0,0 +1,653 @@ +"""插件包安装的外部调用边界测试。""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, call + +import pytest +from fastapi import FastAPI +from pydantic import ValidationError + +from app.adapters.external import market +from app.adapters.external.market import PluginHelper +from app.adapters.system.plugin.package import PluginPackageManager +from app.agent.tools.impl import _plugin_tool_utils +from app.api.endpoints import plugin as plugin_endpoint +from app.runtime.config import global_vars +from app.schemas.plugin import ( + PluginSourceChangeRequest, + PluginSourceIdentity, + PluginSourceInstallRequest, + PluginSourceOptions, +) +from app.startup.initializers import plugins as plugins_initializer + +REPO_URL = "https://github.com/example/moviepilot-plugins" + + +def test_package_manager_sync_preserves_external_install_contract() -> None: + """同步包适配器必须调用包级入口,不能再次进入公开 Gateway。""" + helper = Mock() + helper._PluginHelper__install_package.return_value = (True, "installed") + manager = PluginPackageManager(helper=helper) + + result = manager.install( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version="1.2.3", + force_install=False, + ) + + assert result == (True, "installed") + helper._PluginHelper__install_package.assert_called_once_with( + pid="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version="1.2.3", + force_install=False, + ) + helper.install.assert_not_called() + + +@pytest.mark.asyncio +async def test_package_manager_async_preserves_external_install_contract() -> None: + """异步包适配器必须调用包级入口,不能再次进入公开 Gateway。""" + helper = Mock() + helper._PluginHelper__async_install_package = AsyncMock(return_value=(True, "installed")) + manager = PluginPackageManager(helper=helper) + + result = await manager.async_install( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version="1.2.3", + force_install=False, + ) + + assert result == (True, "installed") + helper._PluginHelper__async_install_package.assert_awaited_once_with( + pid="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version="1.2.3", + force_install=False, + ) + helper.async_install.assert_not_called() + + +def test_external_sync_helper_rejects_until_gateway_is_configured( + monkeypatch, +) -> None: + """外部同步入口在宿主未装配来源门禁时不得直接写入插件包。""" + helper = PluginHelper() + monkeypatch.setattr( + market, + "_plugin_install_gateway", + market._unconfigured_plugin_install_gateway, + ) + + success, message = helper.install( + "DemoPlugin", + REPO_URL, + "v3", + "1.2.3", + True, + ) + assert success is False + assert message + + +@pytest.mark.asyncio +async def test_external_async_helper_rejects_until_gateway_is_configured( + monkeypatch, +) -> None: + """外部异步入口在宿主未装配来源门禁时不得直接写入插件包。""" + helper = PluginHelper() + monkeypatch.setattr( + market, + "_async_plugin_install_gateway", + market._unconfigured_async_plugin_install_gateway, + ) + + success, message = await helper.async_install( + "DemoPlugin", + REPO_URL, + "v3", + "1.2.3", + True, + ) + assert success is False + assert message + + +def test_external_sync_helper_uses_configured_gateway(monkeypatch) -> None: + """外部同步调用必须把所有参数交给宿主统一安装用例。""" + gateway = Mock(return_value=(False, "source conflict")) + monkeypatch.setattr(market, "_plugin_install_gateway", gateway) + + result = PluginHelper().install("DemoPlugin", REPO_URL, "v3", "1.2.3", True) + + assert result == (False, "source conflict") + gateway.assert_called_once_with("DemoPlugin", REPO_URL, "v3", "1.2.3", True) + + +def test_sync_gateway_returns_failure_when_runtime_loop_is_unavailable( + monkeypatch, +) -> None: + """主事件循环释放后,同步兼容入口应稳定返回失败结果。""" + gateway = Mock() + monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", None) + + result = plugins_initializer._run_plugin_install_sync( + gateway, + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version="1.2.3", + force=False, + local_sync=False, + explicit_source=True, + ) + + assert result == (False, "插件安装服务当前不可用") + gateway.install.assert_not_called() + + +@pytest.mark.asyncio +async def test_external_async_helper_uses_configured_gateway(monkeypatch) -> None: + """外部异步调用必须把所有参数交给宿主统一安装用例。""" + + async def gateway(*args): + """返回统一 Gateway 的结果。""" + seen.append(args) + return False, "source conflict" + + seen = [] + monkeypatch.setattr(market, "_async_plugin_install_gateway", gateway) + + result = await PluginHelper().async_install( + "DemoPlugin", + REPO_URL, + "v3", + "1.2.3", + True, + ) + + assert result == (False, "source conflict") + assert seen == [("DemoPlugin", REPO_URL, "v3", "1.2.3", True)] + + +@pytest.mark.asyncio +async def test_external_async_helper_preserves_failure_tuple_on_gateway_error( + monkeypatch, +) -> None: + """公开异步 Helper 在持久化等内部异常下仍返回兼容二元组。""" + gateway = Mock() + gateway.install = AsyncMock(side_effect=RuntimeError("persistence unavailable")) + + async def install(*args): + """按组合根的真实参数映射进入公开异步兼容包装层。""" + plugin_id, repo_url, package_version, release_version, force = args + return await plugins_initializer._run_plugin_install_async( + gateway, + plugin_id=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force=force, + local_sync=False, + explicit_source=bool(repo_url), + ) + + monkeypatch.setattr(market, "_async_plugin_install_gateway", install) + + result = await PluginHelper().async_install( + "DemoPlugin", + REPO_URL, + "v3", + "1.2.3", + True, + ) + + assert result == (False, "persistence unavailable") + + +@pytest.mark.asyncio +async def test_http_install_does_not_treat_repo_url_as_explicit_source( + monkeypatch, +) -> None: + """旧 GET 安装入口不能把兼容参数误当成管理员明确选源。""" + gateway = Mock() + gateway.install = AsyncMock( + return_value=SimpleNamespace(success=True, message="") + ) + monkeypatch.setattr( + plugin_endpoint, + "get_plugin_install_service", + lambda: gateway, + ) + + result = await plugin_endpoint.install( + "DemoPlugin", + REPO_URL, + "1.2.3", + False, + None, + ) + + assert result.success is True + gateway.install.assert_awaited_once_with( + plugin_id="DemoPlugin", + repo_url=None, + release_version="1.2.3", + force=False, + explicit_source=False, + ) + + +@pytest.mark.asyncio +async def test_http_explicit_source_install_uses_explicit_gateway_mode( + monkeypatch, +) -> None: + """专用来源安装入口必须把管理员选择传给统一 Gateway。""" + gateway = Mock() + gateway.install = AsyncMock( + return_value=SimpleNamespace(success=True, message="") + ) + monkeypatch.setattr( + plugin_endpoint, + "get_plugin_install_service", + lambda: gateway, + ) + + result = await plugin_endpoint.install_plugin_from_source( + "DemoPlugin", + PluginSourceInstallRequest( + repo_url=REPO_URL, + release_version="1.2.3", + force=True, + ), + None, + ) + + assert result.success is True + gateway.install.assert_awaited_once_with( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + release_version="1.2.3", + force=True, + explicit_source=True, + ) + + +@pytest.mark.asyncio +async def test_http_source_change_requires_revision_and_explicit_gateway_mode( + monkeypatch, +) -> None: + """管理员换源入口必须把目标仓库和精确 revision 交给统一 Gateway。""" + gateway = Mock() + gateway.install = AsyncMock( + return_value=SimpleNamespace(success=True, message="") + ) + monkeypatch.setattr( + plugin_endpoint, + "get_plugin_install_service", + lambda: gateway, + ) + + result = await plugin_endpoint.change_plugin_source( + "DemoPlugin", + PluginSourceChangeRequest( + repo_url=REPO_URL, + expected_revision=7, + release_version="1.2.3", + ), + None, + ) + + assert result.success is True + gateway.install.assert_awaited_once_with( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + release_version="1.2.3", + force=True, + explicit_source=True, + source_change=True, + expected_revision=7, + ) + + +@pytest.mark.asyncio +async def test_http_source_identity_returns_current_cas_evidence( + monkeypatch, +) -> None: + """来源查询只公开确认和显式换源所需的最小身份字段。""" + identity = SimpleNamespace( + plugin_id="DemoPlugin", + trusted_source_type=SimpleNamespace(value="official"), + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=SimpleNamespace(value="official_default"), + payload_source_type=SimpleNamespace(value="local"), + payload_source_key=None, + revision=7, + ) + persistence = Mock() + persistence.get_identity = AsyncMock(return_value=identity) + monkeypatch.setattr( + plugin_endpoint, + "get_plugin_persistence", + lambda: persistence, + ) + + result = await plugin_endpoint.get_plugin_source_identity( + "DemoPlugin", + None, + ) + + assert result.success is True + assert isinstance(result.data, PluginSourceIdentity) + assert result.data.plugin_id == "DemoPlugin" + assert result.data.trusted_source_key == "github:jxxghp/moviepilot-plugins" + assert result.data.payload_source_type == "local" + assert result.data.revision == 7 + + +def test_source_change_schema_rejects_invalid_revision_and_blank_repo() -> None: + """显式换源请求在进入业务层前拒绝无来源或无效 revision。""" + with pytest.raises(ValidationError): + PluginSourceChangeRequest(repo_url=" ", expected_revision=1) + with pytest.raises(ValidationError): + PluginSourceChangeRequest(repo_url=REPO_URL, expected_revision=0) + with pytest.raises(ValidationError): + PluginSourceChangeRequest( + repo_url="local://DemoPlugin", + expected_revision=1, + ) + + +@pytest.mark.asyncio +async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> None: + """来源候选接口保留在线选择信息,但本地候选不公开路径。""" + identity = SimpleNamespace( + plugin_id="DemoPlugin", + trusted_source_type=SimpleNamespace(value="official"), + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=SimpleNamespace(value="official_default"), + payload_source_type=SimpleNamespace(value="local"), + payload_source_key=None, + revision=7, + ) + inspection = SimpleNamespace( + plugin_id="DemoPlugin", + inventory_complete=True, + identity=identity, + selection=SimpleNamespace( + status=SimpleNamespace(value="conflict"), + reason="未安装插件存在多个在线来源,不能静默选择", + ), + online_candidates=( + SimpleNamespace( + public_dict=lambda: { + "plugin_id": "DemoPlugin", + "source_type": "official", + "source_key": "github:jxxghp/moviepilot-plugins", + "repo_url": "https://github.com/jxxghp/MoviePilot-Plugins", + "package_generation": "v3", + "plugin_version": "1.0.0", + } + ), + ), + local_candidate=SimpleNamespace( + public_dict=lambda: { + "plugin_id": "DemoPlugin", + "source_type": "local", + "package_generation": "v3", + "plugin_version": "2.0.0-dev", + } + ), + ) + gateway = Mock() + gateway.inspect_source = AsyncMock(return_value=inspection) + monkeypatch.setattr( + plugin_endpoint, + "get_plugin_install_service", + lambda: gateway, + ) + + result = await plugin_endpoint.get_plugin_source_options( + "DemoPlugin", + None, + ) + + assert result.success is True + assert isinstance(result.data, PluginSourceOptions) + assert result.data.identity is not None + assert result.data.identity.revision == 7 + assert [candidate.source_type for candidate in result.data.candidates] == [ + "official", + "local", + ] + assert result.data.candidates[1].repo_url is None + assert "/private/" not in result.model_dump_json() + + +def test_source_api_openapi_uses_structured_contracts() -> None: + """来源查询、初始选源和换源 API 必须公开稳定结构模型。""" + app = FastAPI() + app.include_router(plugin_endpoint.router, prefix="/api/v1/plugin") + + paths = app.openapi()["paths"] + change_operation = paths["/api/v1/plugin/source/{plugin_id}"]["post"] + install_operation = paths["/api/v1/plugin/source/{plugin_id}/install"]["post"] + options_operation = paths["/api/v1/plugin/source/{plugin_id}/options"]["get"] + + change_schema = change_operation["requestBody"]["content"]["application/json"]["schema"] + install_schema = install_operation["requestBody"]["content"]["application/json"]["schema"] + options_schema = options_operation["responses"]["200"]["content"]["application/json"]["schema"] + + assert change_schema["$ref"].endswith("/PluginSourceChangeRequest") + assert install_schema["$ref"].endswith("/PluginSourceInstallRequest") + assert options_schema["$ref"].endswith("/Response_PluginSourceOptions_") + + +@pytest.mark.asyncio +async def test_agent_install_uses_application_gateway( + monkeypatch, +) -> None: + """Agent 安装入口只能转发到唯一 Application Gateway。""" + gateway = Mock() + gateway.install = AsyncMock( + return_value=SimpleNamespace( + success=True, + message="installed", + refreshed_only=False, + ) + ) + monkeypatch.setattr( + _plugin_tool_utils, + "get_plugin_install_service", + lambda: gateway, + ) + + result = await _plugin_tool_utils.install_plugin_runtime( + "DemoPlugin", + REPO_URL, + force=False, + ) + + assert result == (True, "installed", False) + gateway.install.assert_awaited_once_with( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + force=False, + explicit_source=False, + ) + + +def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> None: + """启动组合根必须向 Application 与公开 Helper 发布同一 Gateway。""" + helper = Mock() + gateway_calls = [] + application_calls = [] + gateway = Mock() + sync_runner = Mock(return_value=(True, "installed")) + async_runner = AsyncMock(return_value=(True, "installed")) + + monkeypatch.setattr(plugins_initializer, "PluginHelper", lambda: helper) + monkeypatch.setattr( + plugins_initializer, + "PluginMarketClient", + lambda _helper: Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "PluginPackageManager", + lambda _helper: Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "PluginCandidateInventoryReader", + lambda **_kwargs: Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "PluginInstallCommand", + lambda **_kwargs: Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "PluginInstallGateway", + lambda **_kwargs: gateway, + ) + monkeypatch.setattr( + plugins_initializer, + "PluginInstallationRecoveryService", + lambda **_kwargs: Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "PluginIdentityMigrationService", + lambda **_kwargs: Mock(), + ) + monkeypatch.setattr(plugins_initializer, "get_plugin_manager", Mock()) + monkeypatch.setattr(plugins_initializer, "get_plugin_persistence", Mock()) + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_install_service", + application_calls.append, + ) + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_installation_recovery", + Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_identity_migration", + Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "_run_plugin_install_sync", + sync_runner, + ) + monkeypatch.setattr( + plugins_initializer, + "_run_plugin_install_async", + async_runner, + ) + monkeypatch.setattr( + plugins_initializer, + "PluginDependencyInstaller", + lambda *_args, **_kwargs: Mock(), + ) + for name in ( + "configure_plugin_legacy_import_services", + "configure_plugin_resource_import_preparer", + "configure_site_auth_level_provider", + "configure_installed_plugins_provider", + "configure_plugin_catalog_factory", + "configure_plugin_route_refresher", + "configure_plugin_system", + "configure_plugin_storage", + ): + monkeypatch.setattr(plugins_initializer, name, Mock()) + + def configure_gateway(**kwargs) -> None: + """记录组合根提供给外部 Helper 的同步/异步端口。""" + gateway_calls.append(kwargs) + + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_install_gateway", + configure_gateway, + raising=False, + ) + + plugins_initializer.configure_plugin_services() + + assert application_calls == [gateway] + assert len(gateway_calls) == 1 + assert callable(gateway_calls[0]["install"]) + assert callable(gateway_calls[0]["async_install"]) + + assert gateway_calls[0]["install"]( + "DemoPlugin", + REPO_URL, + "v3", + "1.2.3", + False, + ) == (True, "installed") + assert asyncio.run( + gateway_calls[0]["async_install"]( + "DemoPlugin", + REPO_URL, + "v3", + "1.2.3", + False, + ) + ) == (True, "installed") + local_repo_url = "local://DemoPlugin?path=/private/plugins&version=v3" + assert gateway_calls[0]["install"]( + "DemoPlugin", + local_repo_url, + "v3", + None, + True, + ) == (True, "installed") + assert asyncio.run( + gateway_calls[0]["async_install"]( + "DemoPlugin", + local_repo_url, + "v3", + None, + True, + ) + ) == (True, "installed") + online_expected = { + "plugin_id": "DemoPlugin", + "repo_url": "", + "package_version": "v3", + "release_version": "1.2.3", + "force": False, + "local_sync": False, + "explicit_source": False, + } + local_expected = { + "plugin_id": "DemoPlugin", + "repo_url": local_repo_url, + "package_version": "v3", + "release_version": None, + "force": True, + "local_sync": True, + "explicit_source": True, + } + assert sync_runner.call_args_list == [ + call(gateway, **online_expected), + call(gateway, **local_expected), + ] + assert async_runner.await_args_list == [ + call(gateway, **online_expected), + call(gateway, **local_expected), + ] diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 3893e5773..508dd493e 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -141,7 +141,6 @@ def _patch_sync_remote_install(helper, monkeypatch, meta: dict, monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None) monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove")) monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, "")) - monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: calls.append("refresh")) def fake_release(_pid, _user_repo, _release_tag): calls.append("release") @@ -2080,11 +2079,11 @@ demo = { index = "private" } (True, ""), ) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message - assert ["remove", "release", "refresh"] == calls + assert ["remove", "release"] == calls def test_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch): """ @@ -2104,11 +2103,11 @@ demo = { index = "private" } (True, ""), ) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message - assert ["remove", "release", "remove", "filelist", "refresh"] == calls + assert ["remove", "release", "remove", "filelist"] == calls def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch): """ @@ -2128,7 +2127,7 @@ demo = { index = "private" } (False, "获取文件列表失败"), ) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert not success assert "获取文件列表失败" == message @@ -2152,11 +2151,11 @@ demo = { index = "private" } (True, ""), ) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message - assert ["remove", "filelist", "refresh"] == calls + assert ["remove", "filelist"] == calls def test_install_rejects_release_without_version(self, monkeypatch): """ @@ -2175,7 +2174,7 @@ demo = { index = "private" } (True, ""), ) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert not success assert f"未在插件清单中找到 {PLUGIN_ID} 的版本号" in message @@ -2199,7 +2198,7 @@ demo = { index = "private" } ) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0")) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert not success assert "MoviePilot 版本 >=9.0.0" in message @@ -2228,7 +2227,7 @@ demo = { index = "private" } lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}], ) - success, message = helper.install( + success, message = helper._PluginHelper__install_package( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.3", force_install=True ) @@ -2260,7 +2259,7 @@ demo = { index = "private" } lambda *_args: [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}], ) - success, message = helper.install( + success, message = helper._PluginHelper__install_package( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) @@ -2290,7 +2289,7 @@ demo = { index = "private" } lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}], ) - success, message = helper.install( + success, message = helper._PluginHelper__install_package( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) @@ -2307,7 +2306,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper().install("", REPO_URL) + success, message = PluginHelper()._PluginHelper__install_package("", REPO_URL) assert not success assert "参数错误" == message @@ -2321,7 +2320,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper().install(PLUGIN_ID, "not-a-repo-url") + success, message = PluginHelper()._PluginHelper__install_package(PLUGIN_ID, "not-a-repo-url") assert not success assert "不支持的插件仓库地址格式" == message @@ -2338,7 +2337,7 @@ demo = { index = "private" } helper = PluginHelper() monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: None) - success, message = helper.install(PLUGIN_ID, REPO_URL) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL) assert not success assert f"{PLUGIN_ID} 没有找到适用于当前版本的插件" == message @@ -2359,10 +2358,9 @@ demo = { index = "private" } monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None) monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: None) monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, "")) - monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: None) monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", lambda *_args: (True, "")) - success, message = helper.install(PLUGIN_ID, REPO_URL, force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, force_install=True) assert success assert "" == message @@ -2399,9 +2397,8 @@ demo = { index = "private" } }, ) monkeypatch.setattr("app.adapters.external.market.PLUGIN_DIR", runtime_root) - monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: True) - success, message = helper.install( + success, message = helper._PluginHelper__install_package( PLUGIN_ID, helper.make_local_repo_url(PLUGIN_ID, repo_path, "v2"), force_install=True, @@ -2431,11 +2428,11 @@ demo = { index = "private" } (True, ""), ) - success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message - assert ["remove", "release", "remove", "filelist", "refresh"] == calls + assert ["remove", "release", "remove", "filelist"] == calls def test_async_install_uses_release_package_when_asset_is_available(self, monkeypatch): """ @@ -2455,13 +2452,12 @@ demo = { index = "private" } ) success, message = asyncio.run( - helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success assert "" == message - assert calls[:2] == ["remove", "release"] - assert calls[2][0] == "to_thread" + assert calls == ["remove", "release"] def test_async_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch): """ @@ -2482,13 +2478,12 @@ demo = { index = "private" } ) success, message = asyncio.run( - helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success assert "" == message - assert calls[:4] == ["remove", "release", "remove", "filelist"] - assert calls[4][0] == "to_thread" + assert calls == ["remove", "release", "remove", "filelist"] def test_async_install_old_release_version_uses_release_asset_without_filelist_fallback(self, monkeypatch): """ @@ -2515,7 +2510,7 @@ demo = { index = "private" } monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases) success, message = asyncio.run( - helper.async_install( + helper._PluginHelper__async_install_package( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) ) @@ -2547,7 +2542,7 @@ demo = { index = "private" } monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases) success, message = asyncio.run( - helper.async_install( + helper._PluginHelper__async_install_package( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) ) @@ -2575,7 +2570,7 @@ demo = { index = "private" } ) success, message = asyncio.run( - helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert not success @@ -2608,7 +2603,7 @@ demo = { index = "private" } monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist) success, message = asyncio.run( - helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success @@ -2641,7 +2636,7 @@ demo = { index = "private" } monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist) success, message = asyncio.run( - helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success @@ -3521,7 +3516,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper().install("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins") + success, message = PluginHelper()._PluginHelper__install_package("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins") assert not success assert "本地插件来源与插件ID不匹配" == message diff --git a/tests/test_plugin_identity_startup_migration.py b/tests/test_plugin_identity_startup_migration.py new file mode 100644 index 000000000..da405d36c --- /dev/null +++ b/tests/test_plugin_identity_startup_migration.py @@ -0,0 +1,545 @@ +"""存量插件身份启动迁移的来源和顺序合同测试。""" + +from __future__ import annotations + +import asyncio +from contextlib import nullcontext +from dataclasses import replace +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginIdentityConflictError, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.identity_migration import PluginIdentityMigrationService +from app.application.plugin.source import ( + CandidateInventory, + LocalCandidateRead, + MarketRead, + PluginMarketCandidate, +) +from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult +from app.startup.initializers import plugins as plugins_initializer + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +OFFICIAL_REPO = "https://github.com/jxxghp/MoviePilot-Plugins" +OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins" +THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins" +THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins" + + +class _Persistence: + """提供可观察 CAS 竞争的内存迁移持久化端口。""" + + def __init__(self, identities: tuple[PluginIdentity, ...] = ()) -> None: + self.identities = { + identity.normalized_plugin_id: identity for identity in identities + } + self.fail_create = False + self.fail_bind = False + + async def get_identity(self, plugin_id: str) -> PluginIdentity | None: + """按规范物理 ID 返回当前身份。""" + return self.identities.get(plugin_id.lower()) + + async def migrate_identity( + self, + identity: PluginIdentity, + *, + expected_revision: int | None, + ) -> PluginIdentity: + """模拟首次身份 CAS。""" + assert expected_revision is None + if self.fail_create or identity.normalized_plugin_id in self.identities: + raise PluginIdentityConflictError("create conflict") + self.identities[identity.normalized_plugin_id] = identity + return identity + + async def bind_online_identity( + self, + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + """模拟未绑定身份的 revision CAS。""" + current = self.identities.get(identity.normalized_plugin_id) + if ( + self.fail_bind + or current is None + or current.revision != expected_revision + ): + raise PluginIdentityConflictError("bind conflict") + self.identities[identity.normalized_plugin_id] = identity + return identity + + +def _candidate( + plugin_id: str, + *, + source_type: TrustedPluginSourceType, + source_key: str, + repo_url: str, +) -> PluginMarketCandidate: + """构造一个 V3 在线候选。""" + return PluginMarketCandidate( + plugin_id=plugin_id, + source_key=source_key, + source_type=source_type, + repo_url=repo_url, + package_generation="v3", + plugin_version="1.0.0", + ) + + +def _inventory( + *candidates: PluginMarketCandidate, + failed_market: bool = False, +) -> CandidateInventory: + """构造完整或部分失败的市场库存。""" + reads = [ + MarketRead.present( + OFFICIAL_REPO, + candidates, + package_generation="v3", + ) + ] + expected_markets = [OFFICIAL_REPO] + if failed_market: + failed_repo = "https://github.com/unavailable/MoviePilot-Plugins" + reads.append( + MarketRead.failure( + failed_repo, + "unavailable", + package_generation="v3", + ) + ) + expected_markets.append(failed_repo) + return CandidateInventory( + market_reads=tuple(reads), + expected_markets=tuple(expected_markets), + expected_generations=("v3",), + local_read=LocalCandidateRead.absent(), + ) + + +def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity: + """构造尚未绑定在线来源的存量身份。""" + return PluginIdentity( + plugin_id=plugin_id, + normalized_plugin_id=plugin_id.lower(), + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + binding_basis=PluginBindingBasis.LEGACY_UNBOUND, + payload_source_type=PluginPayloadSourceType.UNKNOWN, + payload_source_key=None, + declared_version=None, + package_generation=None, + system_version=None, + supports_v3=None, + supports_v3t=None, + payload_receipt=None, + revision=1, + created_at=NOW, + updated_at=NOW, + bound_at=None, + payload_applied_at=None, + ) + + +def _service( + persistence: _Persistence, + inventory: CandidateInventory, + installed: list[str], + *, + virtual: set[str] | None = None, +) -> PluginIdentityMigrationService: + """装配固定库存和安装清单的迁移服务。""" + virtual_ids = virtual or set() + return PluginIdentityMigrationService( + persistence=persistence, + inventory=AsyncMock(return_value=inventory), + installed_plugins=lambda: installed, + is_virtual_instance=lambda plugin_id: plugin_id in virtual_ids, + clock=lambda: NOW, + ) + + +@pytest.mark.asyncio +async def test_migration_binds_official_and_unique_third_party_sources() -> None: + """官方默认和完整库存中的唯一第三方来源都可建立更新绑定。""" + persistence = _Persistence() + inventory = _inventory( + _candidate( + "OfficialPlugin", + source_type=TrustedPluginSourceType.OFFICIAL, + source_key=OFFICIAL_SOURCE, + repo_url=OFFICIAL_REPO, + ), + _candidate( + "ThirdPartyPlugin", + source_type=TrustedPluginSourceType.THIRD_PARTY, + source_key=THIRD_PARTY_SOURCE, + repo_url=THIRD_PARTY_REPO, + ), + ) + + result = await _service( + persistence, + inventory, + ["OfficialPlugin", "ThirdPartyPlugin", "VirtualPlugin"], + virtual={"VirtualPlugin"}, + ).migrate() + + assert result.created == 2 + assert result.bound == 2 + assert result.unbound == 0 + assert result.skipped == 1 + official = persistence.identities["officialplugin"] + third_party = persistence.identities["thirdpartyplugin"] + assert official.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT + assert official.payload_source_type is PluginPayloadSourceType.UNKNOWN + assert third_party.binding_basis is PluginBindingBasis.TOFU + assert third_party.payload_source_type is PluginPayloadSourceType.UNKNOWN + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failed_market", (False, True)) +async def test_migration_keeps_ambiguous_or_incomplete_third_party_unbound( + failed_market: bool, +) -> None: + """多来源或库存读取失败时不得猜测第三方更新来源。""" + candidates = ( + _candidate( + "DemoPlugin", + source_type=TrustedPluginSourceType.THIRD_PARTY, + source_key=THIRD_PARTY_SOURCE, + repo_url=THIRD_PARTY_REPO, + ), + ) + if not failed_market: + candidates += ( + _candidate( + "DemoPlugin", + source_type=TrustedPluginSourceType.THIRD_PARTY, + source_key="github:second/moviepilot-plugins", + repo_url="https://github.com/second/MoviePilot-Plugins", + ), + ) + persistence = _Persistence() + + result = await _service( + persistence, + _inventory(*candidates, failed_market=failed_market), + ["DemoPlugin"], + ).migrate() + + assert result.created == 1 + assert result.bound == 0 + assert result.unbound == 1 + identity = persistence.identities["demoplugin"] + assert identity.binding_basis is PluginBindingBasis.LEGACY_UNBOUND + assert identity.trusted_source_key is None + + +@pytest.mark.asyncio +async def test_migration_later_binds_legacy_identity_without_rewriting_payload() -> None: + """后续市场证据充分时只升级可信来源,不改写未知存量载荷。""" + legacy = _legacy("DemoPlugin") + persistence = _Persistence((legacy,)) + inventory = _inventory( + _candidate( + "demoplugin", + source_type=TrustedPluginSourceType.THIRD_PARTY, + source_key=THIRD_PARTY_SOURCE, + repo_url=THIRD_PARTY_REPO, + ) + ) + + result = await _service( + persistence, + inventory, + ["demoplugin"], + ).migrate() + + assert result.bound == 1 + identity = persistence.identities["demoplugin"] + assert identity.plugin_id == "DemoPlugin" + assert identity.created_at == legacy.created_at + assert identity.revision == 2 + assert identity.binding_basis is PluginBindingBasis.TOFU + assert identity.payload_source_type is PluginPayloadSourceType.UNKNOWN + + +@pytest.mark.asyncio +async def test_migration_accepts_concurrent_create_winner() -> None: + """首次身份 CAS 竞争已有赢家时,迁移跳过而不覆盖最终身份。""" + persistence = _Persistence() + inventory = _inventory( + _candidate( + "DemoPlugin", + source_type=TrustedPluginSourceType.OFFICIAL, + source_key=OFFICIAL_SOURCE, + repo_url=OFFICIAL_REPO, + ) + ) + + async def create_conflict( + identity: PluginIdentity, + *, + expected_revision: int | None, + ) -> PluginIdentity: + assert expected_revision is None + persistence.identities[identity.normalized_plugin_id] = identity + raise PluginIdentityConflictError("concurrent create") + + persistence.migrate_identity = create_conflict # type: ignore[method-assign] + + result = await _service(persistence, inventory, ["DemoPlugin"]).migrate() + + assert result.created == 0 + assert result.skipped == 1 + assert persistence.identities["demoplugin"].trusted_source_key == OFFICIAL_SOURCE + + +@pytest.mark.asyncio +async def test_migration_accepts_concurrent_bind_winner() -> None: + """存量绑定 CAS 已由其他执行者推进时,迁移保留赢家并幂等结束。""" + persistence = _Persistence((_legacy("DemoPlugin"),)) + inventory = _inventory( + _candidate( + "DemoPlugin", + source_type=TrustedPluginSourceType.THIRD_PARTY, + source_key=THIRD_PARTY_SOURCE, + repo_url=THIRD_PARTY_REPO, + ) + ) + + async def bind_conflict( + identity: PluginIdentity, + *, + expected_revision: int, + ) -> PluginIdentity: + assert expected_revision == 1 + persistence.identities[identity.normalized_plugin_id] = identity + raise PluginIdentityConflictError("concurrent bind") + + persistence.bind_online_identity = bind_conflict # type: ignore[method-assign] + + result = await _service(persistence, inventory, ["DemoPlugin"]).migrate() + + assert result.bound == 0 + assert result.skipped == 1 + winner = persistence.identities["demoplugin"] + assert winner.revision == 2 + assert winner.trusted_source_key == THIRD_PARTY_SOURCE + + +@pytest.mark.asyncio +async def test_migration_does_not_replace_existing_bound_or_local_identity() -> None: + """重复启动不得覆盖已绑定在线来源或本地开发身份。""" + bound = replace( + _legacy("BoundPlugin"), + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=OFFICIAL_SOURCE, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + bound_at=NOW, + ) + local = replace( + _legacy("LocalPlugin"), + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="1.0.0-dev", + package_generation="v3", + payload_receipt="sha256:" + "1" * 64, + payload_applied_at=NOW, + ) + persistence = _Persistence((bound, local)) + inventory = _inventory( + _candidate( + "BoundPlugin", + source_type=TrustedPluginSourceType.OFFICIAL, + source_key=OFFICIAL_SOURCE, + repo_url=OFFICIAL_REPO, + ), + _candidate( + "LocalPlugin", + source_type=TrustedPluginSourceType.OFFICIAL, + source_key=OFFICIAL_SOURCE, + repo_url=OFFICIAL_REPO, + ), + ) + + result = await _service( + persistence, + inventory, + ["BoundPlugin", "LocalPlugin", "BOUNDPLUGIN"], + ).migrate() + + assert result.created == 0 + assert result.bound == 0 + assert result.skipped == 3 + assert persistence.identities["boundplugin"] == bound + assert persistence.identities["localplugin"] == local + + +@pytest.mark.asyncio +async def test_collect_online_restore_plugins_requires_trust_and_local_payload() -> None: + """仅在线可信来源仍绑定的本地载荷需要进入启动恢复候选。""" + trusted_local = replace( + _legacy("TrustedLocal"), + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=OFFICIAL_SOURCE, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="9.9.10", + package_generation="v3", + payload_receipt="sha256:" + "2" * 64, + bound_at=NOW, + payload_applied_at=NOW, + ) + local_only = replace( + _legacy("LocalOnly"), + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="1.0.0-dev", + package_generation="v3", + payload_receipt="sha256:" + "3" * 64, + payload_applied_at=NOW, + ) + online = replace( + _legacy("OnlinePayload"), + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=OFFICIAL_SOURCE, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key=OFFICIAL_SOURCE, + declared_version="1.2.0", + package_generation="v3", + payload_receipt="sha256:" + "4" * 64, + bound_at=NOW, + payload_applied_at=NOW, + ) + persistence = _Persistence((trusted_local, local_only, online)) + + result = await plugins_initializer._collect_online_restore_plugins( + persistence, + ["TrustedLocal", "TRUSTEDLOCAL", "LocalOnly", "OnlinePayload", "bad-id"], + ) + + assert result == {"trustedlocal"} + + +@pytest.mark.asyncio +async def test_sync_runs_identity_migration_before_automatic_install( + monkeypatch, +) -> None: + """启动自动同步必须在存量来源迁移完成后才能读取和替换载荷。""" + order: list[str] = [] + manager = MagicMock() + manager.mutation.return_value = nullcontext() + + def sync(_token, *, online_restore_plugins): + order.append("sync") + assert online_restore_plugins == {"demoplugin"} + return [] + + manager.sync.side_effect = sync + manager.async_install_plugin_missing_dependencies_with_status = AsyncMock( + return_value=PluginDependencyInstallResult(missing=[], success=True) + ) + manager.get_plugin_runtime_statuses.return_value = {} + manager.classify_plugins.return_value = MagicMock(ready=()) + manager.running_plugins = {} + migration = MagicMock() + + async def migrate() -> None: + order.append("migrate") + + migration.migrate = migrate + identity = replace( + _legacy(), + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=OFFICIAL_SOURCE, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="9.9.10", + package_generation="v3", + payload_receipt="sha256:" + "5" * 64, + bound_at=NOW, + payload_applied_at=NOW, + ) + persistence = MagicMock() + + async def get_identity(_plugin_id: str) -> PluginIdentity: + order.append("identity") + return identity + + persistence.get_identity = get_identity + config = MagicMock() + config.get.return_value = ["DemoPlugin"] + + async def execute(_loop, task, _name): + return task() + + monkeypatch.setattr( + plugins_initializer.global_vars, + "CURRENT_EVENT_LOOP", + asyncio.get_running_loop(), + ) + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_services", + lambda: order.append("configure"), + ) + monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) + monkeypatch.setattr( + plugins_initializer, + "get_plugin_identity_migration", + lambda: migration, + ) + monkeypatch.setattr( + plugins_initializer, + "get_plugin_persistence", + lambda: persistence, + ) + monkeypatch.setattr( + plugins_initializer, + "get_configured_system_config", + lambda: config, + ) + monkeypatch.setattr(plugins_initializer, "execute_task", execute) + + assert await plugins_initializer.sync_plugins() is False + assert order == ["configure", "migrate", "identity", "sync"] + + +@pytest.mark.asyncio +async def test_sync_stops_before_automatic_install_when_identity_migration_fails( + monkeypatch, +) -> None: + """存量身份无法持久化时,启动同步不得继续读取或替换插件载荷。""" + manager = MagicMock() + manager.mutation.return_value = nullcontext() + migration = MagicMock() + migration.migrate = AsyncMock(side_effect=RuntimeError("database unavailable")) + + monkeypatch.setattr( + plugins_initializer, + "configure_plugin_services", + lambda: None, + ) + monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) + monkeypatch.setattr( + plugins_initializer, + "get_plugin_identity_migration", + lambda: migration, + ) + + assert await plugins_initializer.sync_plugins() is False + manager.sync.assert_not_called() diff --git a/tests/test_plugin_identity_transitions.py b/tests/test_plugin_identity_transitions.py new file mode 100644 index 000000000..9770dcff1 --- /dev/null +++ b/tests/test_plugin_identity_transitions.py @@ -0,0 +1,388 @@ +"""插件来源身份专用转换命令的 CAS 合同测试。""" + +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginIdentityConflictError, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore +from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel +from app.db.uow import SqlAlchemyUnitOfWork + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins" +THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins" + + +def _identity( + plugin_id: str = "DemoPlugin", + *, + trusted_source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL, + trusted_source_key: str | None = OFFICIAL_SOURCE, + binding_basis: PluginBindingBasis = PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type: PluginPayloadSourceType = PluginPayloadSourceType.OFFICIAL, + payload_source_key: str | None = OFFICIAL_SOURCE, +) -> PluginIdentity: + """构造一份带完整在线载荷审计事实的插件身份。""" + return PluginIdentity( + plugin_id=plugin_id, + normalized_plugin_id=plugin_id.lower(), + trusted_source_type=trusted_source_type, + trusted_source_key=trusted_source_key, + binding_basis=binding_basis, + payload_source_type=payload_source_type, + payload_source_key=payload_source_key, + declared_version="1.0.0", + package_generation="v3", + system_version=None, + supports_v3=None, + supports_v3t=None, + payload_receipt="sha256:" + "0" * 64, + revision=1, + created_at=NOW, + updated_at=NOW, + bound_at=NOW if trusted_source_type is not TrustedPluginSourceType.UNKNOWN else None, + payload_applied_at=NOW, + ) + + +@pytest.fixture +def identity_store(tmp_path): + """创建可验证事务回滚和 revision CAS 的独立 SQLite 身份表。""" + engine = sa.create_engine(f"sqlite:///{tmp_path / 'plugin-identity.db'}") + PluginIdentityModel.__table__.create(engine) + factory = sessionmaker(bind=engine) + try: + yield TransactionalPluginIdentityStore(factory) + finally: + engine.dispose() + + +def _third_party_target(identity: PluginIdentity) -> PluginIdentity: + """构造一次明确指向第三方在线仓库的换源目标。""" + return replace( + identity, + trusted_source_type=TrustedPluginSourceType.THIRD_PARTY, + trusted_source_key=THIRD_PARTY_SOURCE, + binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE, + payload_source_type=PluginPayloadSourceType.THIRD_PARTY, + payload_source_key=THIRD_PARTY_SOURCE, + declared_version="2.0.0", + updated_at=NOW + timedelta(seconds=1), + bound_at=NOW + timedelta(seconds=1), + payload_applied_at=NOW + timedelta(seconds=1), + ) + + +def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity: + """构造尚未建立可信来源且没有已知载荷的存量身份。""" + return PluginIdentity( + plugin_id=plugin_id, + normalized_plugin_id=plugin_id.lower(), + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + binding_basis=PluginBindingBasis.LEGACY_UNBOUND, + payload_source_type=PluginPayloadSourceType.UNKNOWN, + payload_source_key=None, + declared_version=None, + package_generation=None, + system_version=None, + supports_v3=None, + supports_v3t=None, + payload_receipt=None, + revision=1, + created_at=NOW, + updated_at=NOW, + bound_at=None, + payload_applied_at=None, + ) + + +def _online_binding_target( + identity: PluginIdentity, + *, + source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY, + source_key: str = THIRD_PARTY_SOURCE, + updated_at: datetime = NOW + timedelta(seconds=1), +) -> PluginIdentity: + """构造用户明确选定在线仓库后的首次绑定目标。""" + return replace( + identity, + trusted_source_type=source_type, + trusted_source_key=source_key, + binding_basis=PluginBindingBasis.EXPLICIT_INSTALL, + payload_source_type=PluginPayloadSourceType(source_type.value), + payload_source_key=source_key, + declared_version="2.0.0", + package_generation="v3", + payload_receipt="sha256:" + "2" * 64, + updated_at=updated_at, + bound_at=updated_at, + payload_applied_at=updated_at, + ) + + +def test_change_source_commits_explicit_online_transition(identity_store) -> None: + """显式换源必须保留创建时间并只推进一个 revision。""" + original = identity_store.compare_and_set(_identity(), expected_revision=None) + + changed = identity_store.change_source( + _third_party_target(original), + expected_revision=original.revision, + ) + + assert changed.trusted_source_type is TrustedPluginSourceType.THIRD_PARTY + assert changed.trusted_source_key == THIRD_PARTY_SOURCE + assert changed.payload_source_type is PluginPayloadSourceType.THIRD_PARTY + assert changed.payload_source_key == THIRD_PARTY_SOURCE + assert changed.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE + assert changed.created_at == original.created_at + assert changed.revision == original.revision + 1 + assert identity_store.get(original.plugin_id) == changed + + +def test_change_source_rejects_revision_competition(identity_store) -> None: + """换源目标使用旧 revision 时不能覆盖已经提交的身份。""" + original = identity_store.compare_and_set(_identity(), expected_revision=None) + changed = identity_store.change_source( + _third_party_target(original), + expected_revision=original.revision, + ) + + stale_target = replace( + _third_party_target(original), + trusted_source_key=OFFICIAL_SOURCE, + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + payload_source_key=OFFICIAL_SOURCE, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + updated_at=NOW + timedelta(seconds=2), + bound_at=NOW + timedelta(seconds=2), + payload_applied_at=NOW + timedelta(seconds=2), + ) + with pytest.raises(PluginIdentityConflictError, match="revision"): + identity_store.change_source(stale_target, expected_revision=original.revision) + + assert identity_store.get(original.plugin_id) == changed + + +def test_change_source_rejects_same_source_and_local_payload(identity_store) -> None: + """换源必须改变实际在线来源,且不能以本地载荷冒充在线换源。""" + original = identity_store.compare_and_set(_identity(), expected_revision=None) + + same_source = replace( + original, + binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE, + updated_at=NOW + timedelta(seconds=1), + bound_at=NOW + timedelta(seconds=1), + payload_applied_at=NOW + timedelta(seconds=1), + ) + with pytest.raises(PluginIdentityConflictError, match="来源必须变化"): + identity_store.change_source( + same_source, + expected_revision=original.revision, + ) + + local_payload = replace( + _third_party_target(original), + payload_source_type=PluginPayloadSourceType.LOCAL, + payload_source_key=None, + ) + with pytest.raises(PluginIdentityConflictError, match="在线载荷"): + identity_store.change_source( + local_payload, + expected_revision=original.revision, + ) + assert identity_store.get(original.plugin_id) == original + + +def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> None: + """本地绑定只能把存量未绑定行转换为本地专属身份。""" + legacy = _legacy_identity() + original = identity_store.compare_and_set(legacy, expected_revision=None) + local = replace( + original, + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="2.0.0-dev", + package_generation="v3", + payload_receipt="sha256:" + "1" * 64, + updated_at=NOW + timedelta(seconds=1), + payload_applied_at=NOW + timedelta(seconds=1), + ) + + changed = identity_store.bind_local( + local, + expected_revision=original.revision, + ) + + assert changed.trusted_source_type is TrustedPluginSourceType.UNKNOWN + assert changed.binding_basis is PluginBindingBasis.LOCAL_ONLY + assert changed.payload_source_type is PluginPayloadSourceType.LOCAL + assert changed.created_at == original.created_at + assert changed.revision == 2 + assert identity_store.get(original.plugin_id) == changed + + +def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) -> None: + """显式在线安装可绑定存量未知来源,也可承接先本地开发的插件。""" + legacy = identity_store.compare_and_set( + _legacy_identity("LegacyPlugin"), + expected_revision=None, + ) + legacy_bound = identity_store.bind_online( + _online_binding_target(legacy), + expected_revision=legacy.revision, + ) + + local = replace( + _legacy_identity("LocalPlugin"), + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="2.0.0-dev", + package_generation="v3", + payload_receipt="sha256:" + "1" * 64, + updated_at=NOW + timedelta(seconds=1), + payload_applied_at=NOW + timedelta(seconds=1), + ) + local = identity_store.compare_and_set(local, expected_revision=None) + local_bound = identity_store.bind_online( + _online_binding_target( + local, + source_type=TrustedPluginSourceType.OFFICIAL, + source_key=OFFICIAL_SOURCE, + updated_at=NOW + timedelta(seconds=2), + ), + expected_revision=local.revision, + ) + + assert legacy_bound.trusted_source_key == THIRD_PARTY_SOURCE + assert legacy_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL + assert legacy_bound.revision == 2 + assert local_bound.trusted_source_key == OFFICIAL_SOURCE + assert local_bound.payload_source_type is PluginPayloadSourceType.OFFICIAL + assert local_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL + assert local_bound.revision == 2 + + +def test_bind_online_rejects_bound_identity_and_stale_revision(identity_store) -> None: + """首次在线绑定不能覆盖已有可信来源,也不能使用失效 revision。""" + bound = identity_store.compare_and_set(_identity(), expected_revision=None) + with pytest.raises(PluginIdentityConflictError, match="未绑定"): + identity_store.bind_online( + _third_party_target(bound), + expected_revision=bound.revision, + ) + + legacy = identity_store.compare_and_set( + _legacy_identity("StalePlugin"), + expected_revision=None, + ) + target = _online_binding_target(legacy) + identity_store.bind_online(target, expected_revision=legacy.revision) + with pytest.raises(PluginIdentityConflictError, match="revision"): + identity_store.bind_online(target, expected_revision=legacy.revision) + + +def test_first_local_install_still_uses_ordinary_create(identity_store) -> None: + """未安装插件的首次本地载荷仍可由普通 create 建立身份。""" + local = replace( + _identity(), + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + payload_source_key=None, + declared_version="2.0.0-dev", + payload_receipt="sha256:" + "1" * 64, + bound_at=None, + ) + + created = identity_store.compare_and_set(local, expected_revision=None) + + assert created.binding_basis is PluginBindingBasis.LOCAL_ONLY + assert created.payload_source_type is PluginPayloadSourceType.LOCAL + assert created.revision == 1 + + +def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -> None: + """本地绑定不能绕过已绑定身份或 revision 条件。""" + original = identity_store.compare_and_set(_identity(), expected_revision=None) + local = replace( + original, + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + payload_source_key=None, + bound_at=None, + declared_version="2.0.0-dev", + payload_receipt="sha256:" + "1" * 64, + updated_at=NOW + timedelta(seconds=1), + payload_applied_at=NOW + timedelta(seconds=1), + ) + with pytest.raises(PluginIdentityConflictError, match="legacy_unbound"): + identity_store.bind_local(local, expected_revision=original.revision) + + legacy = replace( + _identity("LegacyPlugin"), + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + binding_basis=PluginBindingBasis.LEGACY_UNBOUND, + payload_source_type=PluginPayloadSourceType.UNKNOWN, + payload_source_key=None, + declared_version=None, + package_generation=None, + payload_receipt=None, + bound_at=None, + payload_applied_at=None, + ) + identity_store.compare_and_set(legacy, expected_revision=None) + changed = replace( + legacy, + binding_basis=PluginBindingBasis.LOCAL_ONLY, + payload_source_type=PluginPayloadSourceType.LOCAL, + declared_version="2.0.0-dev", + package_generation="v3", + payload_receipt="sha256:" + "1" * 64, + updated_at=NOW + timedelta(seconds=1), + payload_applied_at=NOW + timedelta(seconds=1), + ) + identity_store.bind_local(changed, expected_revision=1) + with pytest.raises(PluginIdentityConflictError, match="revision"): + identity_store.bind_local(changed, expected_revision=1) + + +def test_ordinary_writer_still_rejects_binding_change(identity_store) -> None: + """普通 writer 不能借 CAS 参数伪装成来源绑定转换。""" + original = identity_store.compare_and_set(_identity(), expected_revision=None) + with pytest.raises(PluginIdentityConflictError, match="不能改变"): + identity_store.compare_and_set( + _third_party_target(original), + expected_revision=original.revision, + ) + assert identity_store.get(original.plugin_id) == original + + +def test_transition_rolls_back_when_commit_fails(identity_store, monkeypatch) -> None: + """转换提交失败时必须回滚暂存的身份变化。""" + original = identity_store.compare_and_set(_identity(), expected_revision=None) + target = _third_party_target(original) + + def fail_commit(_unit_of_work: SqlAlchemyUnitOfWork) -> None: + """模拟数据库提交失败。""" + raise RuntimeError("commit failed") + + monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit) + with pytest.raises(RuntimeError, match="commit failed"): + identity_store.change_source(target, expected_revision=original.revision) + + assert identity_store.get(original.plugin_id) == original diff --git a/tests/test_plugin_install_admission.py b/tests/test_plugin_install_admission.py new file mode 100644 index 000000000..236809c82 --- /dev/null +++ b/tests/test_plugin_install_admission.py @@ -0,0 +1,375 @@ +"""插件来源准入与目标身份规划测试。""" + +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest + +from app.application.plugin.admission import ( + PluginInstallAdmissionRequest, + PluginSourceAdmissionError, + admit_plugin_install, +) +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.source import ( + CandidateInventory, + MarketRead, + PluginLocalCandidate, + PluginMarketCandidate, +) + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +OFFICIAL = "github:jxxghp/moviepilot-plugins" +THIRD_PARTY = "github:example/moviepilot-plugins" + + +def _online_candidate( + *, + source_key: str = OFFICIAL, + source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL, +) -> PluginMarketCandidate: + """构造一个可安装在线候选。""" + owner_repo = source_key.removeprefix("github:") + return PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key=source_key, + source_type=source_type, + repo_url=f"https://github.com/{owner_repo}", + package_generation="v3", + plugin_version="2.0.0", + dto={"system_version": ">=3.0.0", "v3": True, "v3t": False}, + ) + + +def _identity() -> PluginIdentity: + """构造已经绑定官方来源的旧载荷身份。""" + return PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=OFFICIAL, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key=OFFICIAL, + declared_version="1.0.0", + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=None, + payload_receipt="sha256:" + "0" * 64, + revision=3, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + + +def _inventory(*candidates, local_candidates=()) -> CandidateInventory: + """构造完整市场库存。""" + return CandidateInventory( + (MarketRead.present("https://github.com/example/plugins", candidates),), + tuple(local_candidates), + ) + + +def test_same_source_update_preserves_binding_and_advances_payload() -> None: + """同源更新只推进载荷事实,不改变既有可信绑定依据。""" + current = _identity() + admission = admit_plugin_install( + _inventory(_online_candidate()), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins", + explicit_source=False, + ), + identity=current, + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "1" * 64, + applied_at=NOW, + ) + + assert target.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT + assert target.trusted_source_key == OFFICIAL + assert target.revision == 4 + assert target.declared_version == "2.0.0" + + +def test_first_online_binding_uses_payload_commit_time() -> None: + """首次在线绑定在载荷提交时生效,不能早于身份创建时间。""" + applied_at = NOW + timedelta(seconds=1) + admission = admit_plugin_install( + _inventory(_online_candidate()), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins", + explicit_source=True, + ), + identity=None, + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "6" * 64, + applied_at=applied_at, + ) + + assert target.created_at == applied_at + assert target.updated_at == applied_at + assert target.bound_at == applied_at + assert target.payload_applied_at == applied_at + + +def test_force_semantics_cannot_authorize_source_change() -> None: + """普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。""" + with pytest.raises(PluginSourceAdmissionError, match="普通安装不能改变"): + admit_plugin_install( + _inventory( + _online_candidate(), + _online_candidate( + source_key=THIRD_PARTY, + source_type=TrustedPluginSourceType.THIRD_PARTY, + ), + ), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url="https://github.com/example/moviepilot-plugins", + explicit_source=True, + ), + identity=_identity(), + now=NOW, + ) + + +@pytest.mark.parametrize("revision", [None, 2, 4]) +def test_source_change_requires_exact_identity_revision(revision: int | None) -> None: + """显式换源必须携带当前身份的精确 revision。""" + with pytest.raises(PluginSourceAdmissionError, match="revision"): + admit_plugin_install( + _inventory( + _online_candidate( + source_key=THIRD_PARTY, + source_type=TrustedPluginSourceType.THIRD_PARTY, + ) + ), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url="https://github.com/example/moviepilot-plugins", + explicit_source=True, + source_change=True, + expected_revision=revision, + ), + identity=_identity(), + now=NOW, + ) + + +def test_source_change_builds_explicit_transition() -> None: + """合法换源把 trusted 与 payload 一起指向明确选择的新仓库。""" + candidate = _online_candidate( + source_key=THIRD_PARTY, + source_type=TrustedPluginSourceType.THIRD_PARTY, + ) + admission = admit_plugin_install( + _inventory(candidate), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url=candidate.repo_url, + explicit_source=True, + source_change=True, + expected_revision=3, + ), + identity=_identity(), + now=NOW, + ) + + applied_at = NOW + timedelta(seconds=1) + target = admission.build_identity( + payload_receipt="sha256:" + "2" * 64, + applied_at=applied_at, + ) + + assert target.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE + assert target.trusted_source_key == THIRD_PARTY + assert target.payload_source_key == THIRD_PARTY + assert target.bound_at == applied_at + + +def test_local_payload_preserves_existing_online_trust() -> None: + """本地开发载荷覆盖时保留此前可信在线来源,便于之后同源恢复。""" + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?package_version=v3", + package_generation="v3", + plugin_version="2.0.0-dev", + dto={"v3": True}, + ) + admission = admit_plugin_install( + _inventory(local_candidates=(local,)), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url=local.repo_url, + explicit_source=True, + ), + identity=_identity(), + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "3" * 64, + applied_at=NOW, + ) + + assert target.trusted_source_key == OFFICIAL + assert target.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT + assert target.payload_source_type is PluginPayloadSourceType.LOCAL + assert target.payload_source_key is None + + +def test_first_local_payload_creates_local_only_identity() -> None: + """首次本地安装不会伪造在线可信来源。""" + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?package_version=v3", + package_generation="v3", + plugin_version="2.0.0-dev", + dto={"v3": True}, + ) + admission = admit_plugin_install( + _inventory(local_candidates=(local,)), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url=local.repo_url, + explicit_source=True, + ), + identity=None, + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "4" * 64, + applied_at=NOW, + ) + + assert target.binding_basis is PluginBindingBasis.LOCAL_ONLY + assert target.trusted_source_type is TrustedPluginSourceType.UNKNOWN + + +def test_sanitized_local_reference_selects_configured_candidate_without_path() -> None: + """脱敏本地来源标识仍能选择配置内候选,但公共投影不暴露路径。""" + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url=( + "local://DemoPlugin?path=/private/secret/plugins&version=v3" + ), + package_generation="v3", + plugin_version="2.0.0-dev", + dto={"v3": True, "path": "/private/secret/plugins"}, + ) + + admission = admit_plugin_install( + _inventory(local_candidates=(local,)), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url="local://DemoPlugin?version=v3", + ), + identity=None, + now=NOW, + ) + + assert admission.candidate is local + public = admission.candidate.public_dict() + assert public == { + "plugin_id": "DemoPlugin", + "source_type": "local", + "package_generation": "v3", + "plugin_version": "2.0.0-dev", + } + assert "/private/secret/plugins" not in str(public) + + +def test_legacy_identity_can_bind_explicit_online_source() -> None: + """存量未绑定身份可在管理员明确选源后建立在线可信来源。""" + legacy = replace( + _identity(), + trusted_source_type=TrustedPluginSourceType.UNKNOWN, + trusted_source_key=None, + binding_basis=PluginBindingBasis.LEGACY_UNBOUND, + payload_source_type=PluginPayloadSourceType.UNKNOWN, + payload_source_key=None, + declared_version=None, + package_generation=None, + supports_v3=None, + payload_receipt=None, + bound_at=None, + payload_applied_at=None, + ) + candidate = _online_candidate( + source_key=THIRD_PARTY, + source_type=TrustedPluginSourceType.THIRD_PARTY, + ) + admission = admit_plugin_install( + _inventory(candidate), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url=candidate.repo_url, + explicit_source=True, + ), + identity=legacy, + now=NOW, + ) + + target = admission.build_identity( + payload_receipt="sha256:" + "5" * 64, + applied_at=NOW, + ) + + assert target.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL + assert target.trusted_source_key == THIRD_PARTY + assert target.revision == 4 + + +def test_source_change_rejects_local_payload_reference() -> None: + """带 revision 的显式换源只能切换在线可信来源。""" + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?path=/private/plugins&version=v3", + package_generation="v3", + plugin_version="2.0.0-dev", + ) + current = _identity() + + with pytest.raises( + PluginSourceAdmissionError, + match="显式换源只接受在线插件仓库", + ): + admit_plugin_install( + _inventory(local_candidates=(local,)), + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url="local://DemoPlugin?version=v3", + explicit_source=True, + source_change=True, + expected_revision=current.revision, + ), + identity=current, + now=NOW, + ) diff --git a/tests/test_plugin_install_command.py b/tests/test_plugin_install_command.py index 3218ef034..8168ce630 100644 --- a/tests/test_plugin_install_command.py +++ b/tests/test_plugin_install_command.py @@ -1,153 +1,388 @@ +"""插件安装事务用例的端到端副作用顺序与补偿测试。""" + import asyncio from contextlib import nullcontext -from unittest.mock import AsyncMock, Mock, patch +from dataclasses import replace +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import pytest -from app.schemas.exception import ( - DatabaseWorkerClosedError, - DatabaseWorkerOverloadedError, - PersistenceUnavailableError, +from app.application.plugin.admission import ( + PluginInstallAdmission, + PluginInstallAdmissionRequest, + admit_plugin_install, +) +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, ) from app.application.plugin.install import PluginInstallCommand -from app.runtime.extensions.plugin.admission import PluginMutationAdmission +from app.application.plugin.recovery import PluginInstallationRecoveryService +from app.application.plugin.source import ( + CandidateInventory, + MarketRead, + PluginMarketCandidate, +) +from app.application.plugin.transaction import ( + PluginInstallationConflictError, + PluginInstallationPhase, + PluginInstallationRecord, +) +from app.schemas.exception import ( + DatabaseWorkerClosedError, + PersistenceUnavailableError, + PluginMutationRejectedError, +) + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins" +SOURCE_KEY = "github:jxxghp/moviepilot-plugins" +RECEIPT = "sha256:" + "a" * 64 + + +def _identity(*, version: str = "1.0.0", revision: int = 1) -> PluginIdentity: + """构造与市场候选匹配的已提交来源身份。""" + return PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=SOURCE_KEY, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key=SOURCE_KEY, + declared_version=version, + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=False, + payload_receipt=RECEIPT, + revision=revision, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + + +def _admission( + *, + identity: PluginIdentity | None = None, + version: str = "1.0.0", +) -> PluginInstallAdmission: + """冻结一个可供安装用例消费的官方 V3 候选。""" + candidate = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key=SOURCE_KEY, + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v3", + plugin_version=version, + dto={"v3": True}, + ) + inventory = CandidateInventory( + ( + MarketRead.present( + REPO_URL, + (candidate,), + package_generation="v3", + ), + ) + ) + return admit_plugin_install( + inventory, + request=PluginInstallAdmissionRequest( + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_repo_url=REPO_URL, + explicit_source=identity is None, + ), + identity=identity, + now=NOW, + ) + + +class _PersistenceSpy: + """记录安装 journal 的异步状态转移,并允许注入持久化失败。""" + + def __init__( + self, + calls: list[str], + *, + create_error: Exception | None = None, + target_error: Exception | None = None, + commit_error: Exception | None = None, + delete_error: Exception | None = None, + identity: PluginIdentity | None = None, + ) -> None: + self.calls = calls + self.records: dict[str, PluginInstallationRecord] = {} + self.create_error = create_error + self.target_error = target_error + self.commit_error = commit_error + self.delete_error = delete_error + self.identity = identity + + async def create_installation( + self, + record: PluginInstallationRecord, + ) -> PluginInstallationRecord: + """保存 PREPARED journal。""" + self.calls.append("journal_create") + if self.create_error: + raise self.create_error + if any( + existing.plugin_id.lower() == record.plugin_id.lower() + and existing.phase + in { + PluginInstallationPhase.PREPARED, + PluginInstallationPhase.COMMITTED, + } + for existing in self.records.values() + ): + raise PluginInstallationConflictError( + f"插件 {record.plugin_id} 存在未收尾安装事务" + ) + self.records[record.transaction_id] = record + return record + + async def get_installation( + self, + transaction_id: str, + ) -> PluginInstallationRecord | None: + """返回 journal 创建结果,模拟超时后的确认读取。""" + self.calls.append("journal_get") + return self.records.get(transaction_id) + + async def list_installations(self) -> list[PluginInstallationRecord]: + """返回当前未收尾 journal,供连续事务恢复测试复用。""" + return list(self.records.values()) + + async def get_identity(self, _plugin_id: str) -> PluginIdentity | None: + """返回恢复核验使用的当前身份事实。""" + return self.identity + + async def set_installation_target( + self, + transaction_id: str, + *, + membership_target: bool, + identity_target: PluginIdentity | None, + ) -> PluginInstallationRecord: + """保存最终 membership 与身份 revision。""" + self.calls.append("journal_target") + if self.target_error: + raise self.target_error + record = self.records[transaction_id] + record = replace( + record, + membership_target=membership_target, + identity_target_revision=( + identity_target.revision if identity_target else None + ), + ) + self.records[transaction_id] = record + return record + + async def commit_installation( + self, + transaction_id: str, + *, + identity_target: PluginIdentity | None, + ) -> PluginInstallationRecord: + """把 journal 推进到 COMMITTED。""" + self.calls.append("journal_commit") + if self.commit_error: + raise self.commit_error + record = self.records[transaction_id] + record = replace( + record, + phase=PluginInstallationPhase.COMMITTED, + membership_target=True, + identity_target_revision=( + identity_target.revision if identity_target else None + ), + ) + self.records[transaction_id] = record + return record + + async def delete_installation( + self, + transaction_id: str, + *, + expected_phase: PluginInstallationPhase, + ) -> bool: + """按 phase 删除已补偿或已收尾 journal。""" + self.calls.append("journal_delete") + if self.delete_error: + raise self.delete_error + record = self.records.get(transaction_id) + if record is None or record.phase is not expected_phase: + return False + del self.records[transaction_id] + return True def _command( *, - installed=None, - plugin_ids=None, - compatibility=None, + persistence: _PersistenceSpy | None = None, + installed: list[str] | None = None, + plugin_ids: list[str] | None = None, installer=None, - reporter=None, - writer=None, - reloader=None, - refresher=None, checkpointer=None, - committer=None, - rollback=None, + package_restore=None, + package_rollback=None, + package_cleanup=None, + package_stage_backup=None, + package_activate_backup=None, + package_finalize_backup=None, + package_commit=None, + payload_receipt=None, + reporter=None, + target_reloader=None, + rollback_reloader=None, + registration_refresher=None, mutation=None, package_write_guard=None, -): - """构造可观测每一步副作用的插件安装命令。""" - return PluginInstallCommand( - installed_plugins_reader=Mock(return_value=installed or []), - installed_plugins_writer=writer or AsyncMock(), - plugin_ids_provider=Mock(return_value=plugin_ids or []), - compatibility_checker=compatibility or AsyncMock(return_value=None), - package_installer=installer or AsyncMock(return_value=(True, "ok")), - package_checkpointer=checkpointer or AsyncMock(return_value=object()), - package_committer=committer or AsyncMock(), - package_rollback=rollback or AsyncMock(), - install_reporter=reporter or AsyncMock(), - plugin_reloader=reloader or AsyncMock(), - registration_refresher=refresher or AsyncMock(), - mutation=mutation or (lambda _operation: nullcontext()), - package_write_guard=package_write_guard - or (lambda _plugin_id: nullcontext()), + transaction_id: str = "txn-demo", +) -> tuple[PluginInstallCommand, _PersistenceSpy, list[str]]: + """构造只含窄端口的安装命令,并返回可观测调用记录。""" + calls: list[str] = persistence.calls if persistence else [] + persistence = persistence or _PersistenceSpy(calls) + checkpoint = SimpleNamespace( + plugin_existed=False, + persistent_backup_existed=False, ) - -@pytest.mark.asyncio -async def test_install_failure_stops_before_report_persistence_and_reload(): - """包安装失败后恢复文件快照,且不得写配置、刷新或上报。""" - reporter = AsyncMock() - writer = AsyncMock() - reloader = AsyncMock() - rollback = AsyncMock() - command = _command( - installer=AsyncMock(return_value=(False, "download failed")), - reporter=reporter, - writer=writer, - reloader=reloader, - rollback=rollback, - ) - - result = await command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is False - assert result.package_installed is False - assert result.failure_stage == "package_install" - assert result.rollback.file_restored is True - assert result.rollback.dependency_supported is False - assert "插件文件已恢复" not in result.message - assert "Python依赖变更不支持自动回滚" not in result.message - rollback.assert_awaited_once() - reporter.assert_not_awaited() - writer.assert_not_awaited() - reloader.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_sealed_install_rejects_before_package_guard_and_checkpoint() -> None: - """安装事务在封口后不进入监控抑制,也不创建文件快照。""" - admission = PluginMutationAdmission() - admission.seal() - package_guard = Mock(return_value=nullcontext()) - checkpointer = AsyncMock() - - result = await _command( - mutation=admission.hold, - package_write_guard=package_guard, - checkpointer=checkpointer, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is False - assert result.failure_stage == "admission" - assert "停机阶段" in result.message - package_guard.assert_not_called() - checkpointer.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_success_records_completed_install_stages_in_order(): - """成功安装在提交文件快照后再执行非关键远程上报。""" - calls = [] - - async def install(*_args): - calls.append("package") - return True, "installed" - - checkpoint = object() - - async def create_checkpoint(_plugin_id): + async def default_checkpoint(_plugin_id: str, _transaction_id: str): + """返回事务级文件快照。""" calls.append("checkpoint") return checkpoint - async def commit(target): - assert target is checkpoint - calls.append("commit") + async def default_installer(**_kwargs): + """表示包载荷安装成功。""" + calls.append("package") + return True, "installed" - async def report(*_args): + async def default_receipt(_plugin_id: str): + """返回稳定的已落盘载荷收据。""" + calls.append("receipt") + return RECEIPT + + async def default_reporter(_plugin_id: str, _repo_url: str | None): + """表示远程安装上报成功。""" calls.append("report") + return True - async def write(_plugins): - calls.append("persist") - - async def reload(_plugin_id): - calls.append("reload") - - async def refresh(_plugin_id): - calls.append("registrations") - - result = await _command( - installer=install, - reporter=report, - writer=write, - reloader=reload, - refresher=refresh, - checkpointer=create_checkpoint, - committer=commit, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", + packages = SimpleNamespace( + async_checkpoint=checkpointer or default_checkpoint, + async_install=installer or default_installer, + async_restore=package_restore or AsyncMock(), + async_cleanup=package_cleanup or AsyncMock(), + async_stage_persistent_backup=package_stage_backup or AsyncMock(), + async_activate_persistent_backup=package_activate_backup or AsyncMock(), + async_finalize_persistent_backup=package_finalize_backup or AsyncMock(), + async_commit=package_commit or AsyncMock(), + async_payload_receipt=payload_receipt or default_receipt, ) + command = PluginInstallCommand( + persistence=persistence, + installed_plugins_reader=lambda: installed or [], + plugin_ids_provider=lambda: plugin_ids or [], + packages=packages, + install_reporter=reporter or default_reporter, + target_reloader=target_reloader or AsyncMock(), + rollback_reloader=rollback_reloader or AsyncMock(), + registration_refresher=registration_refresher or AsyncMock(), + mutation=mutation or (lambda _operation: nullcontext()), + package_write_guard=package_write_guard + or (lambda _plugin_id: nullcontext()), + clock=lambda: NOW, + transaction_id_factory=lambda: transaction_id, + ) + return command, persistence, calls + + +def _journal_record( + phase: PluginInstallationPhase, + *, + transaction_id: str, +) -> PluginInstallationRecord: + """构造占用同一物理插件槽位的旧安装 journal。""" + committed = phase is PluginInstallationPhase.COMMITTED + return PluginInstallationRecord( + transaction_id=transaction_id, + plugin_id="DemoPlugin", + phase=phase, + membership_before=True, + membership_target=True if committed else None, + identity_before_revision=1, + identity_target_revision=2 if committed else None, + package_existed=True, + persistent_backup_existed=True, + created_at=NOW, + updated_at=NOW, + ) + + +async def _execute( + command: PluginInstallCommand, + admission=None, + *, + release_version: str | None = None, + force: bool = False, + **kwargs, +): + """执行测试安装并默认使用当前冻结候选。""" + return await command.execute( + admission=admission or _admission(), + release_version=release_version, + force=force, + **kwargs, + ) + + +@pytest.mark.asyncio +async def test_success_commits_journal_before_report_and_cleans_package_snapshot(): + """成功路径按快照、journal、运行态、数据库提交、清理和上报顺序执行。""" + calls: list[str] = [] + + def mark(name: str): + async def action(_value): + calls.append(name) + + return action + + async def installer(**_kwargs): + calls.append("package") + return True, "installed" + + async def receipt(_plugin_id): + calls.append("receipt") + return RECEIPT + + async def report(_plugin_id, _repo_url): + calls.append("report") + return True + + persistence = _PersistenceSpy(calls) + command, _, _ = _command( + persistence=persistence, + installer=installer, + payload_receipt=receipt, + package_stage_backup=mark("stage_backup"), + package_activate_backup=mark("activate_backup"), + target_reloader=mark("target_reload"), + registration_refresher=mark("registrations"), + package_finalize_backup=mark("finalize_backup"), + package_commit=mark("package_commit"), + reporter=report, + ) + + result = await _execute(command) assert result.success is True assert result.package_installed is True @@ -157,516 +392,617 @@ async def test_success_records_completed_install_stages_in_order(): assert result.reported is True assert calls == [ "checkpoint", + "journal_create", "package", - "persist", - "reload", + "receipt", + "journal_target", + "stage_backup", + "activate_backup", + "target_reload", "registrations", - "commit", + "journal_commit", + "finalize_backup", + "package_commit", + "journal_delete", "report", ] - - -@pytest.mark.asyncio -async def test_existing_plugin_checks_compatibility_without_reinstalling_package(): - """已存在插件只校验兼容性、上报和重载,不重复安装包。""" - installer = AsyncMock() - checkpointer = AsyncMock() - command = _command( - installed=["DemoPlugin"], - plugin_ids=["DemoPlugin"], - installer=installer, - checkpointer=checkpointer, - ) - - result = await command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is True - assert result.refreshed_only is True - assert result.package_installed is False - assert result.installed_list_persisted is False - installer.assert_not_awaited() - checkpointer.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_cancelled_existing_plugin_refresh_restores_runtime_and_registrations(): - """已存在插件刷新被取消时,必须重新收敛运行态和注册。""" - registration_started = asyncio.Event() - calls: list[str] = [] - - async def reload_plugin(_plugin_id: str) -> None: - calls.append("reload") - - async def refresh_registrations(_plugin_id: str) -> None: - calls.append("registrations") - if calls.count("registrations") == 1: - registration_started.set() - await asyncio.Event().wait() - - with patch("app.application.plugin.install.logger.warning") as warning: - task = asyncio.create_task( - _command( - installed=["DemoPlugin"], - plugin_ids=["DemoPlugin"], - reloader=reload_plugin, - refresher=refresh_registrations, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await registration_started.wait() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - assert calls == ["reload", "registrations", "reload", "registrations"] - warning.assert_not_called() - - -@pytest.mark.asyncio -async def test_persistence_failure_restores_package_without_touching_runtime(): - """已安装列表保存失败时恢复文件,且运行态尚未开始切换。""" - checkpoint = object() - rollback = AsyncMock() - reloader = AsyncMock() - command = _command( - checkpointer=AsyncMock(return_value=checkpoint), - writer=AsyncMock(side_effect=RuntimeError("db unavailable")), - rollback=rollback, - reloader=reloader, - ) - - result = await command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is False - assert result.failure_stage == "installed_list_persistence" - assert result.rollback.file_restored is True - assert result.rollback.installed_list_attempted is True - assert result.rollback.runtime_attempted is False - rollback.assert_awaited_once_with(checkpoint) - reloader.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_persistence_exception_after_write_restores_installed_list(): - """清单写入已提交后抛异常时,文件和清单必须一起恢复。""" - persisted: list[list[str]] = [] - checkpoint = object() - rollback = AsyncMock() - - async def write(plugin_ids: list[str]) -> None: - persisted.append(list(plugin_ids)) - if len(persisted) == 1: - raise RuntimeError("write acknowledgement lost") - - result = await _command( - checkpointer=AsyncMock(return_value=checkpoint), - writer=write, - rollback=rollback, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is False - assert result.failure_stage == "installed_list_persistence" - assert result.rollback.installed_list_attempted is True - assert result.rollback.installed_list_restored is True - assert persisted == [["DemoPlugin"], []] - rollback.assert_awaited_once_with(checkpoint) + assert persistence.records == {} @pytest.mark.asyncio @pytest.mark.parametrize( - "error_type", - [DatabaseWorkerClosedError, DatabaseWorkerOverloadedError], + "phase", + [PluginInstallationPhase.PREPARED, PluginInstallationPhase.COMMITTED], ) -async def test_persistence_unavailable_rolls_back_and_reaches_api_boundary( - error_type: type[PersistenceUnavailableError], +async def test_unfinished_journal_blocks_follow_up_before_payload_write( + phase: PluginInstallationPhase, ) -> None: - """持久化能力暂不可用时完成补偿并交由 API 映射为 503。""" - checkpoint = object() - rollback = AsyncMock() - command = _command( - checkpointer=AsyncMock(return_value=checkpoint), - writer=AsyncMock(side_effect=error_type("persistence unavailable")), - rollback=rollback, + """旧 journal 未收尾时,新安装不得写载荷或推进身份 revision。""" + persistence = _PersistenceSpy([]) + old_record = _journal_record(phase, transaction_id=f"old-{phase.value}") + persistence.records[old_record.transaction_id] = old_record + package_install = AsyncMock() + package_restore = AsyncMock() + package_cleanup = AsyncMock() + command, _, _ = _command( + persistence=persistence, + installer=package_install, + package_restore=package_restore, + package_cleanup=package_cleanup, ) - with pytest.raises(error_type): - await command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", + admission = ( + _admission(identity=_identity(revision=2)) + if phase is PluginInstallationPhase.COMMITTED + else _admission() + ) + result = await _execute(command, admission=admission, force=True) + + assert result.success is False + assert result.failure_stage == "journal_prepare_conflict" + assert "未收尾安装事务" in result.message + package_install.assert_not_awaited() + package_restore.assert_awaited_once() + package_cleanup.assert_awaited_once() + assert persistence.records == {old_record.transaction_id: old_record} + + if phase is PluginInstallationPhase.COMMITTED: + checkpoint = SimpleNamespace( + plugin_existed=True, + persistent_backup_existed=True, + ) + recovery_packages = SimpleNamespace( + restore_checkpoint=Mock(return_value=checkpoint), + async_restore=AsyncMock(), + async_cleanup=AsyncMock(), + async_committed_payload_receipt=AsyncMock(return_value=RECEIPT), + async_finalize_persistent_backup=AsyncMock(), + async_commit=AsyncMock(), + ) + persistence.identity = _identity(revision=2) + recovery = PluginInstallationRecoveryService( + persistence=persistence, + packages=recovery_packages, ) - rollback.assert_awaited_once_with(checkpoint) + recovery_result = await recovery.replay() + + assert recovery_result.finalized == 1 + assert persistence.records == {} @pytest.mark.asyncio -async def test_reload_failure_restores_list_files_and_previous_runtime(): - """重载失败时依次恢复已安装列表、包文件和旧运行态。""" - calls = [] - checkpoint = object() - reload_count = 0 +async def test_package_rejection_restores_files_and_removes_prepared_journal(): + """包安装返回失败时不得切换运行态,且必须删除 PREPARED journal。""" + package_restore = AsyncMock() + package_cleanup = AsyncMock() + target_reloader = AsyncMock() + reporter = AsyncMock() - async def write(plugin_ids): - calls.append(("persist", list(plugin_ids))) + async def installer(**_kwargs): + return False, "download failed" - async def rollback(target): - assert target is checkpoint - calls.append(("rollback", target)) - - async def reload(_plugin_id): - nonlocal reload_count - reload_count += 1 - calls.append(("reload", reload_count)) - if reload_count == 1: - raise RuntimeError("route registration failed") - - async def refresh(_plugin_id): - calls.append(("registrations", reload_count)) - - result = await _command( - installed=[], - checkpointer=AsyncMock(return_value=checkpoint), - writer=write, - rollback=rollback, - reloader=reload, - refresher=refresh, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", + command, persistence, _ = _command( + installer=installer, + package_restore=package_restore, + package_cleanup=package_cleanup, + target_reloader=target_reloader, + reporter=reporter, ) + result = await _execute(command) + + assert result.success is False + assert result.failure_stage == "package_install" + assert result.rollback.file_restored is True + assert result.rollback.journal_deleted is True + package_restore.assert_awaited_once() + package_cleanup.assert_awaited_once() + target_reloader.assert_not_awaited() + reporter.assert_not_awaited() + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_journal_create_failure_uses_legacy_rollback_without_journal_cleanup(): + """journal 创建失败时使用兼容回滚,不尝试删除不存在的 journal。""" + package_restore = AsyncMock() + package_cleanup = AsyncMock() + persistence = _PersistenceSpy( + [], + create_error=RuntimeError("database unavailable"), + ) + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + package_cleanup=package_cleanup, + ) + + result = await _execute(command) + + assert result.success is False + assert result.failure_stage == "journal_prepare" + assert result.rollback.file_restored is True + package_restore.assert_awaited_once() + package_cleanup.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_persistence_unavailable_during_journal_prepare_is_rethrown(): + """数据库 worker 暂不可用时完成无 journal 回滚后保留异常语义。""" + package_restore = AsyncMock() + package_cleanup = AsyncMock() + persistence = _PersistenceSpy( + [], + create_error=DatabaseWorkerClosedError("worker closed"), + ) + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + package_cleanup=package_cleanup, + ) + + with pytest.raises(PersistenceUnavailableError): + await _execute(command) + + package_restore.assert_awaited_once() + package_cleanup.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("failure", "failure_stage", "runtime_touched"), + [ + ("receipt", "payload_receipt", False), + ("target", "payload_receipt", False), + ("stage", "persistent_backup_stage", False), + ("activate", "persistent_backup_activate", False), + ("reload", "runtime_reload", True), + ("registrations", "registration_refresh", True), + ], +) +async def test_precommit_failure_restores_files_and_runtime( + failure: str, + failure_stage: str, + runtime_touched: bool, +): + """每个提交前阶段失败都恢复文件,运行态失败还要恢复旧运行态。""" + package_restore = AsyncMock() + rollback_reloader = AsyncMock() + registration_refresher = AsyncMock() + + async def failing_receipt(_plugin_id): + raise RuntimeError("receipt failed") + + async def failing_stage(_value): + raise RuntimeError("backup stage failed") + + async def failing_activate(_value): + raise RuntimeError("backup activation failed") + + async def failing_reload(_plugin_id): + raise RuntimeError("reload failed") + + registration_attempts = 0 + + async def failing_registrations(_plugin_id): + nonlocal registration_attempts + registration_attempts += 1 + if registration_attempts == 1: + raise RuntimeError("registration failed") + + persistence = _PersistenceSpy( + [], + target_error=RuntimeError("target failed") if failure == "target" else None, + ) + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + payload_receipt=(failing_receipt if failure == "receipt" else None), + package_stage_backup=(failing_stage if failure == "stage" else None), + package_activate_backup=( + failing_activate if failure == "activate" else None + ), + target_reloader=failing_reload if failure == "reload" else None, + rollback_reloader=rollback_reloader, + registration_refresher=( + failing_registrations + if failure == "registrations" + else registration_refresher + ), + ) + + result = await _execute(command) + + assert result.success is False + assert result.failure_stage == failure_stage + assert result.rollback.file_restored is True + assert result.rollback.journal_deleted is True + if runtime_touched: + rollback_reloader.assert_awaited_once_with("DemoPlugin") + else: + rollback_reloader.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_database_commit_failure_restores_runtime_before_deleting_journal(): + """数据库最终提交失败时,文件、运行态和 PREPARED journal 必须一起补偿。""" + package_restore = AsyncMock() + rollback_reloader = AsyncMock() + persistence = _PersistenceSpy( + [], + commit_error=RuntimeError("commit failed"), + ) + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + rollback_reloader=rollback_reloader, + ) + + result = await _execute(command) + + assert result.success is False + assert result.failure_stage == "database_commit" + assert result.rollback.file_restored is True + assert result.rollback.runtime_restored is True + assert result.rollback.journal_deleted is True + package_restore.assert_awaited_once() + rollback_reloader.assert_awaited_once_with("DemoPlugin") + + +@pytest.mark.asyncio +async def test_cleanup_failure_keeps_committed_journal_for_replay(): + """数据库已提交但清理失败时不能回滚载荷,journal 必须保留供启动重放。""" + package_finalize = AsyncMock(side_effect=RuntimeError("cleanup unavailable")) + rollback_reloader = AsyncMock() + command, persistence, _ = _command( + package_finalize_backup=package_finalize, + rollback_reloader=rollback_reloader, + ) + + result = await _execute(command) + + assert result.success is True + assert result.checkpoint_cleanup_error == "cleanup unavailable" + assert not result.rollback.file_attempted + assert persistence.records["txn-demo"].phase is PluginInstallationPhase.COMMITTED + rollback_reloader.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_failure_does_not_rollback_committed_install(): + """远程安装上报失败属于非关键副作用,不得撤销已提交本地安装。""" + package_restore = AsyncMock() + reporter = AsyncMock(side_effect=RuntimeError("server unavailable")) + command, persistence, _ = _command( + package_restore=package_restore, + reporter=reporter, + ) + + result = await _execute(command) + + assert result.success is True + assert result.reported is False + assert result.report_error == "server unavailable" + assert "不影响本地安装" in result.message + package_restore.assert_not_awaited() + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_existing_matching_payload_only_refreshes_runtime(): + """同一来源、代际和版本已提交时只刷新运行态,不重复写包或 journal。""" + checkpointer = AsyncMock() + installer = AsyncMock() + reloader = AsyncMock() + refresher = AsyncMock() + reporter = AsyncMock(return_value=True) + command, persistence, _ = _command( + installed=["DemoPlugin"], + plugin_ids=["DemoPlugin"], + checkpointer=checkpointer, + installer=installer, + target_reloader=reloader, + registration_refresher=refresher, + reporter=reporter, + ) + + result = await _execute(command, admission=_admission(identity=_identity())) + + assert result.success is True + assert result.refreshed_only is True + assert result.package_installed is False + assert result.runtime_reloaded is True + checkpointer.assert_not_awaited() + installer.assert_not_awaited() + reloader.assert_awaited_once_with("DemoPlugin") + refresher.assert_awaited_once_with("DemoPlugin") + reporter.assert_awaited_once_with("DemoPlugin", REPO_URL) + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_force_install_replaces_matching_payload_and_local_sync_skips_report(): + """强制安装和本地同步都绕过刷新短路,本地同步还禁止远程上报。""" + installer = AsyncMock(return_value=(True, "synced")) + reporter = AsyncMock(return_value=True) + command, _, _ = _command( + installed=["DemoPlugin"], + plugin_ids=["DemoPlugin"], + installer=installer, + reporter=reporter, + ) + + result = await _execute( + command, + admission=_admission(identity=_identity()), + force=True, + local_sync=True, + ) + + assert result.success is True + assert result.refreshed_only is False + installer.assert_awaited_once_with( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + release_version=None, + force_install=True, + ) + reporter.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_mutation_rejection_happens_before_package_write_guard(): + """运行时封口后拒绝安装,不能进入包写入抑制或文件快照。""" + checkpointer = AsyncMock() + package_guard = Mock(return_value=nullcontext()) + rejected = Mock(side_effect=PluginMutationRejectedError("安装插件 DemoPlugin")) + + command, _, _ = _command( + checkpointer=checkpointer, + mutation=rejected, + package_write_guard=package_guard, + ) + + result = await _execute(command) + + assert result.success is False + assert result.failure_stage == "admission" + package_guard.assert_not_called() + checkpointer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_package_install_waits_for_compensation(): + """包安装被取消时等待文件恢复和 journal 清理完成后再传播取消。""" + started = asyncio.Event() + release = asyncio.Event() + package_restore = AsyncMock() + package_cleanup = AsyncMock() + + async def installer(**_kwargs): + started.set() + await release.wait() + + command, persistence, _ = _command( + installer=installer, + package_restore=package_restore, + package_cleanup=package_cleanup, + ) + task = asyncio.create_task(_execute(command)) + await started.wait() + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + package_restore.assert_awaited_once() + package_cleanup.assert_awaited_once() + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_cancelled_journal_create_resolves_persisted_record_before_rollback(): + """PREPARED 已写入但调用被取消时,必须确认记录并完成补偿。""" + started = asyncio.Event() + release = asyncio.Event() + package_restore = AsyncMock() + package_cleanup = AsyncMock() + persistence = _PersistenceSpy([]) + + async def create(record: PluginInstallationRecord): + persistence.calls.append("journal_create") + persistence.records[record.transaction_id] = record + started.set() + await release.wait() + return record + + persistence.create_installation = create + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + package_cleanup=package_cleanup, + ) + task = asyncio.create_task(_execute(command)) + await started.wait() + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + package_restore.assert_awaited_once() + package_cleanup.assert_awaited_once() + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_cancelled_prepared_commit_rolls_back_after_outcome_check(): + """提交任务确定仍为 PREPARED 时,取消必须先完成旧载荷补偿。""" + started = asyncio.Event() + release = asyncio.Event() + package_restore = AsyncMock() + rollback_reloader = AsyncMock() + persistence = _PersistenceSpy([]) + + async def commit( + transaction_id: str, + *, + identity_target: PluginIdentity | None, + ) -> PluginInstallationRecord: + del transaction_id, identity_target + persistence.calls.append("journal_commit") + started.set() + await release.wait() + raise RuntimeError("commit failed") + + persistence.commit_installation = commit + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + rollback_reloader=rollback_reloader, + ) + task = asyncio.create_task(_execute(command)) + await started.wait() + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + package_restore.assert_awaited_once() + rollback_reloader.assert_awaited_once_with("DemoPlugin") + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_cancelled_committed_install_keeps_journal_for_startup_cleanup(): + """数据库已提交后发生取消时不得回滚新载荷,journal 留给启动收尾。""" + started = asyncio.Event() + release = asyncio.Event() + package_restore = AsyncMock() + package_finalize = AsyncMock() + persistence = _PersistenceSpy([]) + + async def commit( + transaction_id: str, + *, + identity_target: PluginIdentity | None, + ) -> PluginInstallationRecord: + record = replace( + persistence.records[transaction_id], + phase=PluginInstallationPhase.COMMITTED, + membership_target=True, + identity_target_revision=( + identity_target.revision if identity_target else None + ), + ) + persistence.records[transaction_id] = record + started.set() + await release.wait() + return record + + persistence.commit_installation = commit + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + package_finalize_backup=package_finalize, + ) + task = asyncio.create_task(_execute(command)) + await started.wait() + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + package_restore.assert_not_awaited() + package_finalize.assert_not_awaited() + assert persistence.records["txn-demo"].phase is PluginInstallationPhase.COMMITTED + + +@pytest.mark.asyncio +async def test_commit_ack_failure_uses_committed_journal_as_final_fact(): + """提交回执丢失但 journal 已为 COMMITTED 时继续完成新载荷收尾。""" + package_restore = AsyncMock() + persistence = _PersistenceSpy([]) + + async def commit( + transaction_id: str, + *, + identity_target: PluginIdentity | None, + ) -> PluginInstallationRecord: + record = replace( + persistence.records[transaction_id], + phase=PluginInstallationPhase.COMMITTED, + membership_target=True, + identity_target_revision=( + identity_target.revision if identity_target else None + ), + ) + persistence.records[transaction_id] = record + raise RuntimeError("commit acknowledgement lost") + + persistence.commit_installation = commit + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + ) + + result = await _execute(command) + + assert result.success is True + package_restore.assert_not_awaited() + assert persistence.records == {} + + +@pytest.mark.asyncio +async def test_unknown_commit_result_preserves_current_payload_and_journal(): + """数据库最终状态无法读取时不得猜测回滚,必须留待启动恢复。""" + package_restore = AsyncMock() + rollback_reloader = AsyncMock() + persistence = _PersistenceSpy([], commit_error=RuntimeError("commit failed")) + persistence.get_installation = AsyncMock( + side_effect=RuntimeError("database unavailable") + ) + command, _, _ = _command( + persistence=persistence, + package_restore=package_restore, + rollback_reloader=rollback_reloader, + ) + + result = await _execute(command) + + assert result.success is False + assert result.failure_stage == "database_commit_unknown" + package_restore.assert_not_awaited() + rollback_reloader.assert_not_awaited() + assert persistence.records["txn-demo"].phase is PluginInstallationPhase.PREPARED + + +@pytest.mark.asyncio +async def test_runtime_compensation_failure_keeps_prepared_journal(): + """旧运行态未恢复完整时不得删除 PREPARED journal 和恢复材料。""" + package_restore = AsyncMock() + package_cleanup = AsyncMock() + target_reloader = AsyncMock(side_effect=RuntimeError("reload failed")) + rollback_reloader = AsyncMock(side_effect=RuntimeError("rollback failed")) + command, persistence, _ = _command( + package_restore=package_restore, + package_cleanup=package_cleanup, + target_reloader=target_reloader, + rollback_reloader=rollback_reloader, + ) + + result = await _execute(command) + assert result.success is False assert result.failure_stage == "runtime_reload" assert result.rollback.file_restored is True - assert result.rollback.installed_list_restored is True - assert result.rollback.runtime_restored is True - assert result.rollback.registrations_restored is True - assert calls == [ - ("persist", ["DemoPlugin"]), - ("reload", 1), - ("persist", []), - ("rollback", checkpoint), - ("reload", 2), - ("registrations", 2), - ] - - -@pytest.mark.asyncio -async def test_registration_failure_restores_instance_files_and_routes() -> None: - """动态路由刷新失败时恢复列表、文件、旧实例并再次刷新旧注册。""" - calls = [] - checkpoint = object() - refresh_count = 0 - - async def write(plugin_ids): - calls.append(("persist", list(plugin_ids))) - - async def rollback(target): - assert target is checkpoint - calls.append(("rollback", target)) - - async def reload(_plugin_id): - calls.append("reload") - - async def refresh(_plugin_id): - nonlocal refresh_count - refresh_count += 1 - calls.append(("registrations", refresh_count)) - if refresh_count == 1: - raise RuntimeError("route registration failed") - - result = await _command( - checkpointer=AsyncMock(return_value=checkpoint), - writer=write, - rollback=rollback, - reloader=reload, - refresher=refresh, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is False - assert result.failure_stage == "registration_refresh" - assert result.rollback.file_restored is True - assert result.rollback.installed_list_restored is True - assert result.rollback.runtime_restored is True - assert result.rollback.registrations_restored is True - assert calls == [ - ("persist", ["DemoPlugin"]), - "reload", - ("registrations", 1), - ("persist", []), - ("rollback", checkpoint), - "reload", - ("registrations", 2), - ] - - -@pytest.mark.asyncio -async def test_same_plugin_install_lifecycle_is_serialized() -> None: - """同一插件的两个安装调用不得同时修改包、运行态和注册信息。""" - first_started = asyncio.Event() - release_first = asyncio.Event() - calls: list[str] = [] - - async def install(plugin_id, *_args): - calls.append(plugin_id) - if len(calls) == 1: - first_started.set() - await release_first.wait() - return True, "ok" - - command = _command(installer=install) - first = asyncio.create_task( - command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await first_started.wait() - second = asyncio.create_task( - command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await asyncio.sleep(0.02) - assert calls == ["DemoPlugin"] - - release_first.set() - results = await asyncio.gather(first, second) - assert all(result.success for result in results) - assert calls == ["DemoPlugin", "DemoPlugin"] - - -@pytest.mark.asyncio -async def test_cancelled_install_waits_for_rollback_before_releasing_lifecycle() -> None: - """取消安装后先完成包快照补偿,再允许同一插件的新调用进入。""" - install_started = asyncio.Event() - release_install = asyncio.Event() - rollback = AsyncMock() - - async def install(*_args): - install_started.set() - await release_install.wait() - return True, "ok" - - command = _command(installer=install, rollback=rollback) - task = asyncio.create_task( - command.execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await install_started.wait() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - rollback.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_repeated_checkpoint_cancellation_retains_mutation_owner() -> None: - """快照等待被连续取消时,lease 必须保留到快照子任务终态。""" - admission = PluginMutationAdmission() - checkpoint_started = asyncio.Event() - checkpoint_release = asyncio.Event() - checkpoint_finished = asyncio.Event() - - async def checkpoint(_plugin_id: str) -> object: - """阻塞快照创建,直到测试确认 owner 仍被持有。""" - checkpoint_started.set() - await checkpoint_release.wait() - checkpoint_finished.set() - return object() - - task = asyncio.create_task( - _command( - checkpointer=checkpoint, - mutation=admission.hold, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await checkpoint_started.wait() - task.cancel() - await asyncio.sleep(0) - task.cancel() - await asyncio.sleep(0) - - assert task.done() is False - assert admission.seal() == 1 - idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle)) - await asyncio.sleep(0.02) - assert idle_waiter.done() is False - - checkpoint_release.set() - with pytest.raises(asyncio.CancelledError): - await task - await idle_waiter - assert checkpoint_finished.is_set() - assert admission.active_count == 0 - - -@pytest.mark.asyncio -async def test_repeated_rollback_cancellation_retains_mutation_owner() -> None: - """补偿等待被再次连续取消时,lease 必须保留到补偿子任务终态。""" - admission = PluginMutationAdmission() - install_started = asyncio.Event() - rollback_started = asyncio.Event() - rollback_release = asyncio.Event() - rollback_finished = asyncio.Event() - - async def install(*_args) -> tuple[bool, str]: - """阻塞包安装,使首次取消进入补偿路径。""" - install_started.set() - await asyncio.Event().wait() - return True, "ok" - - async def rollback(_checkpoint: object) -> None: - """阻塞文件补偿,直到测试确认 owner 仍被持有。""" - rollback_started.set() - await rollback_release.wait() - rollback_finished.set() - - task = asyncio.create_task( - _command( - installer=install, - rollback=rollback, - mutation=admission.hold, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await install_started.wait() - task.cancel() - await rollback_started.wait() - assert admission.seal() == 1 - idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle)) - - task.cancel() - await asyncio.sleep(0) - task.cancel() - await asyncio.sleep(0.02) - assert task.done() is False - assert idle_waiter.done() is False - assert admission.active_count == 1 - - rollback_release.set() - with pytest.raises(asyncio.CancelledError): - await task - await idle_waiter - assert rollback_finished.is_set() - assert admission.active_count == 0 - - -@pytest.mark.asyncio -async def test_cancelled_persisted_list_is_restored_conservatively() -> None: - """清单写入已产生副作用但尚未返回时取消,也必须恢复原清单。""" - persisted: list[list[str]] = [] - writer_started = asyncio.Event() - rollback = AsyncMock() - - async def writer(plugin_ids: list[str]) -> None: - persisted.append(list(plugin_ids)) - if len(persisted) == 1: - writer_started.set() - await asyncio.Event().wait() - - task = asyncio.create_task( - _command(writer=writer, rollback=rollback).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await writer_started.wait() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - assert persisted == [["DemoPlugin"], []] - rollback.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_cancelled_snapshot_cleanup_does_not_rollback_committed_plugin() -> None: - """运行态提交后清理快照期间取消,不得删除已生效插件。""" - cleanup_started = asyncio.Event() - rollback = AsyncMock() - - async def committer(_checkpoint) -> None: - cleanup_started.set() - await asyncio.Event().wait() - - task = asyncio.create_task( - _command(committer=committer, rollback=rollback).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - ) - await cleanup_started.wait() - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - rollback.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_startup_lifecycle_lock_blocks_plugin_install_until_settlement() -> None: - """启动同步持有全局资格时,插件安装不得穿过启动收口。""" - from app.application.plugin.lifecycle import plugin_lifecycle - - entered = asyncio.Event() - release = asyncio.Event() - - async def startup_scope(): - async with plugin_lifecycle.hold_startup(): - entered.set() - await release.wait() - - startup = asyncio.create_task(startup_scope()) - await entered.wait() - plugin_context = plugin_lifecycle.hold("DemoPlugin") - plugin_scope = asyncio.create_task(plugin_context.__aenter__()) - await asyncio.sleep(0.02) - assert plugin_scope.done() is False - - release.set() - await plugin_scope - await plugin_context.__aexit__(None, None, None) - await startup - - -@pytest.mark.asyncio -async def test_report_failure_does_not_rollback_completed_local_install(): - """统计上报失败属于非关键副作用,不得撤销已成功的本地安装。""" - rollback = AsyncMock() - result = await _command( - reporter=AsyncMock(side_effect=RuntimeError("server unavailable")), - rollback=rollback, - ).execute( - plugin_id="DemoPlugin", - repo_url="https://github.com/demo/plugins", - ) - - assert result.success is True - assert result.runtime_reloaded is True - assert result.reported is False - assert result.report_error == "server unavailable" - assert "不影响本地安装" in result.message - rollback.assert_not_awaited() + assert result.rollback.runtime_restored is False + assert result.rollback.journal_deleted is False + assert result.rollback.errors == ("插件运行态恢复失败:rollback failed",) + package_cleanup.assert_not_awaited() + assert persistence.records["txn-demo"].phase is PluginInstallationPhase.PREPARED diff --git a/tests/test_plugin_install_gateway.py b/tests/test_plugin_install_gateway.py new file mode 100644 index 000000000..b2b25548e --- /dev/null +++ b/tests/test_plugin_install_gateway.py @@ -0,0 +1,304 @@ +"""统一插件安装 Gateway 测试。""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.application.plugin.gateway import PluginInstallGateway +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.source import ( + CandidateInventory, + LocalCandidateRead, + MarketRead, + PluginLocalCandidate, + PluginMarketCandidate, +) + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins" + + +def _inventory() -> CandidateInventory: + """构造仅含官方候选的完整库存。""" + return CandidateInventory(( + MarketRead.present( + REPO_URL, + ( + PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:jxxghp/moviepilot-plugins", + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v3", + plugin_version="1.0.0", + dto={"v3": True}, + ), + ), + package_generation="v3", + ), + )) + + +@pytest.mark.asyncio +async def test_gateway_freezes_admission_before_executing_transaction() -> None: + """Gateway 只把已选中的候选交给事务执行器。""" + executor = AsyncMock() + executor.execute.return_value = type( + "Result", + (), + {"success": True, "message": ""}, + )() + gateway = PluginInstallGateway( + inventory=AsyncMock(return_value=_inventory()), + identity=AsyncMock(return_value=None), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: NOW, + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + explicit_source=True, + ) + + assert result.success is True + admission = executor.execute.await_args.kwargs["admission"] + assert admission.candidate.repo_url == REPO_URL + assert admission.expected_revision is None + + +@pytest.mark.asyncio +async def test_gateway_rejects_source_conflict_before_package_execution() -> None: + """来源准入失败时不进入文件和数据库事务。""" + other = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:example/moviepilot-plugins", + source_type=TrustedPluginSourceType.THIRD_PARTY, + repo_url="https://github.com/example/moviepilot-plugins", + package_generation="v3", + plugin_version="2.0.0", + dto={"v3": True}, + ) + executor = AsyncMock() + gateway = PluginInstallGateway( + inventory=AsyncMock( + return_value=CandidateInventory(( + MarketRead.present(REPO_URL, (_inventory().online_candidates[0], other)), + )) + ), + identity=AsyncMock(return_value=None), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: NOW, + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=None, + ) + + assert result.success is False + assert result.failure_stage == "source_admission" + executor.execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None: + """跨仓聚合不能替代最终可信候选的系统版本兼容门禁。""" + official = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:jxxghp/moviepilot-plugins", + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v3", + plugin_version="1.2.0", + dto={"v3": True, "system_version": ">=99"}, + ) + competing = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:example/moviepilot-plugins", + source_type=TrustedPluginSourceType.THIRD_PARTY, + repo_url="https://github.com/example/moviepilot-plugins", + package_generation="v3", + plugin_version="9.9.10", + dto={"v3": True}, + ) + identity = PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.LOCAL, + payload_source_key=None, + declared_version="9.9.9", + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=None, + payload_receipt="sha256:" + "1" * 64, + revision=3, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + compatibility = Mock(return_value=(False, "当前版本不满足插件要求")) + executor = AsyncMock() + gateway = PluginInstallGateway( + inventory=AsyncMock( + return_value=CandidateInventory(( + MarketRead.present(REPO_URL, (official,)), + MarketRead.present(competing.repo_url, (competing,)), + )) + ), + identity=AsyncMock(return_value=identity), + candidate_compatibility=compatibility, + executor=executor, + clock=lambda: NOW, + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=None, + package_version="v3", + ) + + assert result.success is False + assert result.failure_stage == "source_admission" + assert result.message == "当前版本不满足插件要求" + compatibility.assert_called_once_with(official) + executor.execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_gateway_source_inspection_preserves_sources_and_hides_local_path() -> None: + """来源查询按在线仓归并版本,本地候选只保留类型与版本。""" + official_v3 = _inventory().online_candidates[0] + official_v2 = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:jxxghp/moviepilot-plugins", + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v2", + plugin_version="9.0.0", + dto={"v2": True}, + ) + third_party = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:example/moviepilot-plugins", + source_type=TrustedPluginSourceType.THIRD_PARTY, + repo_url="https://github.com/example/moviepilot-plugins", + package_generation="v3", + plugin_version="2.0.0", + dto={"v3": True}, + ) + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?path=/private/plugins&version=v3", + package_generation="v3", + plugin_version="3.0.0-dev", + dto={"path": "/private/plugins", "v3": True}, + ) + inventory = CandidateInventory( + ( + MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"), + MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"), + MarketRead.present( + third_party.repo_url, + (third_party,), + package_generation="v3", + ), + ), + (local,), + local_read=LocalCandidateRead.present((local,)), + ) + gateway = PluginInstallGateway( + inventory=AsyncMock(return_value=inventory), + identity=AsyncMock(return_value=None), + candidate_compatibility=lambda _candidate: (True, ""), + executor=AsyncMock(), + clock=lambda: NOW, + ) + + inspection = await gateway.inspect_source(plugin_id="DemoPlugin") + + assert [candidate.source_key for candidate in inspection.online_candidates] == [ + "github:jxxghp/moviepilot-plugins", + "github:example/moviepilot-plugins", + ] + assert inspection.online_candidates[0].package_generation == "v3" + assert inspection.local_candidate is local + assert "/private/plugins" not in str(inspection.local_candidate.public_dict()) + + +@pytest.mark.asyncio +async def test_gateway_forwards_explicit_source_change_revision() -> None: + """显式换源的目标来源和 revision 必须冻结到事务准入结果。""" + current = PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key="github:jxxghp/moviepilot-plugins", + declared_version="1.0.0", + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=None, + payload_receipt="sha256:" + "0" * 64, + revision=4, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + candidate = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:example/moviepilot-plugins", + source_type=TrustedPluginSourceType.THIRD_PARTY, + repo_url="https://github.com/example/moviepilot-plugins", + package_generation="v3", + plugin_version="2.0.0", + dto={"v3": True}, + ) + executor = AsyncMock() + executor.execute.return_value = type( + "Result", + (), + {"success": True, "message": ""}, + )() + gateway = PluginInstallGateway( + inventory=AsyncMock( + return_value=CandidateInventory((MarketRead.present(REPO_URL, (candidate,)),)) + ), + identity=AsyncMock(return_value=current), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: NOW, + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=candidate.repo_url, + explicit_source=True, + source_change=True, + expected_revision=4, + ) + + assert result.success is True + admission = executor.execute.await_args.kwargs["admission"] + assert admission.identity_before == current + assert admission.expected_revision == 4 + assert admission.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE + assert admission.trusted_source_key == candidate.source_key diff --git a/tests/test_plugin_install_transaction.py b/tests/test_plugin_install_transaction.py new file mode 100644 index 000000000..aab7ad22a --- /dev/null +++ b/tests/test_plugin_install_transaction.py @@ -0,0 +1,728 @@ +"""插件安装事务记录、SQLite CAS 和 membership 测试。""" + +import copy +import importlib +import os +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from datetime import datetime, timezone + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.orm import sessionmaker + +try: + import psycopg2 as postgres_driver + from psycopg2 import sql + + POSTGRESQL_DIALECT = "postgresql+psycopg2" +except ModuleNotFoundError: + import psycopg as postgres_driver + from psycopg import sql + + POSTGRESQL_DIALECT = "postgresql+psycopg" + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.transaction import ( + PluginInstallationConflictError, + PluginInstallationPhase, + PluginInstallationRecord, + PluginInstallationRecordError, +) +from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore +from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel +from app.db.models.plugininstallation import PluginInstallation +from app.db.models.systemconfig import SystemConfig + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) + + +def _identity( + *, + plugin_id: str = "DemoPlugin", + revision: int = 1, + version: str = "1.0.0", +) -> PluginIdentity: + """构造一份满足来源身份合同的测试身份。""" + return PluginIdentity( + plugin_id=plugin_id, + normalized_plugin_id=plugin_id.lower(), + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key="github:jxxghp/moviepilot-plugins", + declared_version=version, + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=False, + payload_receipt="sha256:" + "0" * 64, + revision=revision, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + + +def _record(**overrides) -> PluginInstallationRecord: + """构造可跨进程恢复的安装事务记录。""" + values = { + "transaction_id": "txn-demo-1", + "plugin_id": "DemoPlugin", + "phase": PluginInstallationPhase.PREPARED, + "membership_before": True, + "membership_target": None, + "identity_before_revision": 1, + "identity_target_revision": None, + "package_existed": True, + "persistent_backup_existed": True, + "created_at": NOW, + "updated_at": NOW, + } + values.update(overrides) + return PluginInstallationRecord(**values) + + +def test_record_keeps_plugin_level_recovery_contract() -> None: + """事务只记录目标插件 membership、CAS revision 和备份存在性。""" + record = _record( + phase="committed", + membership_target=True, + identity_target_revision=2, + ) + + assert record.phase is PluginInstallationPhase.COMMITTED + assert record.membership_before is True + assert record.membership_target is True + assert record.identity_before_revision == 1 + assert record.identity_target_revision == 2 + assert record.package_existed is True + assert record.persistent_backup_existed is True + + +@pytest.mark.parametrize( + "overrides", + [ + {"transaction_id": "bad id"}, + {"plugin_id": " DemoPlugin"}, + {"membership_before": 1}, + {"membership_target": 1}, + {"identity_before_revision": 0}, + {"identity_target_revision": True}, + {"package_existed": 1}, + {"created_at": NOW.replace(tzinfo=None)}, + {"updated_at": NOW.replace(year=2025)}, + {"phase": "committed"}, + ], +) +def test_record_rejects_invalid_recovery_invariants(overrides: dict) -> None: + """事务记录必须拒绝不能用于 CAS 或补偿恢复的状态。""" + with pytest.raises(PluginInstallationRecordError): + _record(**overrides) + + +def test_committed_record_requires_target_membership() -> None: + """COMMITTED 不能指向尚未登记的业务目标。""" + with pytest.raises(PluginInstallationRecordError): + _record(phase=PluginInstallationPhase.COMMITTED) + + +def test_record_schema_version_is_explicit() -> None: + """恢复读取必须拒绝未知 schema version。""" + with pytest.raises(PluginInstallationRecordError): + _record(schema_version=2) + + +def test_record_is_immutable() -> None: + """事务记录提交后不能被调用方原地修改。""" + record = _record() + with pytest.raises(AttributeError): + record.membership_before = False # type: ignore[misc] + + assert replace(record, membership_before=False).membership_before is False + + +class _AtomicSystemConfig: + """用测试 Session 模拟 SystemConfigOper 的配置锁和原子提交。""" + + def __init__(self, factory) -> None: + self._factory = factory + self._lock = threading.RLock() + + def update_atomically(self, key, mutation): + """在测试数据库事务中锁定配置并执行关联写入。""" + with self._lock: + session = self._factory() + try: + with session.begin(): + config = session.execute( + sa.select(SystemConfig) + .where(SystemConfig.key == key) + .with_for_update() + ).scalar_one_or_none() + current = copy.deepcopy(config.value if config else None) + result, value = mutation(session, current) + if config is None: + session.add(SystemConfig(key=key, value=copy.deepcopy(value))) + else: + config.value = copy.deepcopy(value) + session.flush() + return result + finally: + session.close() + + +@pytest.fixture +def installation_store(tmp_path): + """创建带配置、身份和事务表的隔离 SQLite Store。""" + engine = sa.create_engine( + f"sqlite:///{tmp_path / 'plugin-installation.db'}", + connect_args={"check_same_thread": False, "timeout": 5}, + ) + for model in (SystemConfig, PluginIdentityModel, PluginInstallation): + model.__table__.create(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + system_config = _AtomicSystemConfig(factory) + try: + yield engine, factory, TransactionalPluginInstallationStore( + factory, + system_config.update_atomically, + ) + finally: + engine.dispose() + + +def _store_record( + *, + transaction_id: str, + plugin_id: str = "DemoPlugin", + membership_before: bool = False, + identity_before_revision: int | None = None, +) -> PluginInstallationRecord: + """构造 Store 测试用的 PREPARED 记录。""" + return PluginInstallationRecord( + transaction_id=transaction_id, + plugin_id=plugin_id, + phase=PluginInstallationPhase.PREPARED, + membership_before=membership_before, + membership_target=None, + identity_before_revision=identity_before_revision, + identity_target_revision=None, + package_existed=membership_before, + persistent_backup_existed=False, + created_at=NOW, + updated_at=NOW, + ) + + +def _identity_model(identity: PluginIdentity) -> PluginIdentityModel: + """把应用身份转换为测试数据库模型。""" + return PluginIdentityModel( + plugin_id=identity.plugin_id, + normalized_plugin_id=identity.normalized_plugin_id, + trusted_source_type=identity.trusted_source_type.value, + trusted_source_key=identity.trusted_source_key, + binding_basis=identity.binding_basis.value, + payload_source_type=identity.payload_source_type.value, + payload_source_key=identity.payload_source_key, + declared_version=identity.declared_version, + package_generation=identity.package_generation, + supports_v3=identity.supports_v3, + supports_v3t=identity.supports_v3t, + payload_receipt=identity.payload_receipt, + revision=identity.revision, + created_at=identity.created_at.isoformat(), + updated_at=identity.updated_at.isoformat(), + bound_at=identity.bound_at.isoformat() if identity.bound_at else None, + payload_applied_at=( + identity.payload_applied_at.isoformat() + if identity.payload_applied_at + else None + ), + ) + + +def _set_config(factory, value: list[str]) -> None: + """直接准备测试用的安装清单。""" + with factory() as session: + config = session.execute( + sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins") + ).scalar_one_or_none() + if config is None: + session.add(SystemConfig(key="UserInstalledPlugins", value=value)) + else: + config.value = value + session.commit() + + +def _get_config(factory) -> list[str] | None: + """读取测试用的安装清单。""" + with factory() as session: + config = session.execute( + sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins") + ).scalar_one_or_none() + return copy.deepcopy(config.value) if config else None + + +def _upgrade_migration(connection, module_name: str) -> None: + """在当前隔离 schema 中按生产 Alembic 路径执行迁移。""" + migration = importlib.import_module(module_name) + original_op = migration.op + try: + migration.op = Operations(MigrationContext.configure(connection)) + migration.upgrade() + finally: + migration.op = original_op + + +def _set_identity_revision( + factory, + revision: int, + plugin_id: str = "DemoPlugin", +) -> None: + """模拟事务外的身份 revision 更新。""" + with factory() as session: + identity = session.execute( + sa.select(PluginIdentityModel).where( + PluginIdentityModel.normalized_plugin_id == plugin_id.lower() + ) + ).scalar_one() + identity.revision = revision + session.commit() + + +def test_store_round_trips_plugin_level_journal(installation_store) -> None: + """SQLite 往返只保留插件级 membership、revision 和备份标记。""" + _, _, store = installation_store + record = _store_record(transaction_id="install-roundtrip") + + store.create(record) + + restored = store.get(record.transaction_id) + assert restored == record + + +@pytest.mark.parametrize( + "phase", + [PluginInstallationPhase.PREPARED, PluginInstallationPhase.COMMITTED], +) +def test_store_blocks_new_journal_until_previous_phase_is_closed( + installation_store, + phase: PluginInstallationPhase, +) -> None: + """同一物理插件的未收尾 journal 不得被后续事务覆盖。""" + _, _, store = installation_store + existing = _store_record(transaction_id=f"install-{phase.value}") + if phase is PluginInstallationPhase.COMMITTED: + existing = replace(existing, phase=phase, membership_target=True) + store.create(existing) + + with pytest.raises(PluginInstallationConflictError, match="未收尾安装事务"): + store.create( + _store_record( + transaction_id="install-follow-up", + plugin_id="demoplugin", + ) + ) + + assert store.get(existing.transaction_id).phase is phase + assert store.delete( + existing.transaction_id, + expected_phase=phase, + ) is True + assert store.create( + _store_record( + transaction_id="install-follow-up", + plugin_id="demoplugin", + ) + ).transaction_id == "install-follow-up" + + +def test_store_commits_membership_identity_and_phase_atomically(installation_store) -> None: + """membership、身份和 journal phase 必须在一个配置原子事务中提交。""" + _, factory, store = installation_store + before = _identity() + target = replace( + before, + declared_version="2.0.0", + revision=2, + updated_at=NOW.replace(second=1), + payload_applied_at=NOW.replace(second=1), + ) + _set_config(factory, ["OtherPlugin"]) + with factory() as session: + session.add(_identity_model(before)) + session.commit() + + store.create( + _store_record( + transaction_id="install-atomic", + identity_before_revision=before.revision, + ) + ) + staged = store.set_target( + "install-atomic", + membership_target=True, + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) + assert staged.identity_target_revision == target.revision + + committed = store.commit_target( + "install-atomic", + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + assert committed.phase is PluginInstallationPhase.COMMITTED + assert _get_config(factory) == ["OtherPlugin", "DemoPlugin"] + with factory() as session: + identity = session.execute( + sa.select(PluginIdentityModel).where( + PluginIdentityModel.normalized_plugin_id == "demoplugin" + ) + ).scalar_one() + assert identity.revision == 2 + + +def test_store_preserves_other_plugin_membership(installation_store) -> None: + """目标插件提交不能用旧完整清单覆盖其他插件。""" + _, factory, store = installation_store + _set_config(factory, ["OtherPlugin"]) + store.create(_store_record(transaction_id="install-narrow")) + store.set_target( + "install-narrow", + membership_target=True, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + _set_config(factory, ["OtherPlugin", "AnotherPlugin"]) + committed = store.commit_target( + "install-narrow", + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + assert committed.phase is PluginInstallationPhase.COMMITTED + assert _get_config(factory) == ["OtherPlugin", "AnotherPlugin", "DemoPlugin"] + + +def test_store_rejects_target_identity_revision_jump(installation_store) -> None: + """最终写者必须拒绝跳号 revision,避免绕过后续来源 CAS。""" + _, factory, store = installation_store + before = _identity() + with factory() as session: + session.add(_identity_model(before)) + session.commit() + store.create( + _store_record( + transaction_id="install-revision-jump", + identity_before_revision=before.revision, + ) + ) + jumped = replace( + before, + revision=before.revision + 2, + updated_at=NOW.replace(second=1), + ) + + with pytest.raises(PluginInstallationConflictError, match="必须为 2"): + store.set_target( + "install-revision-jump", + membership_target=True, + identity_target=jumped, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + assert store.get("install-revision-jump").identity_target_revision is None + + +def test_store_rejects_membership_and_identity_cas_drift(installation_store) -> None: + """同一插件 membership 或 identity revision 漂移时拒绝覆盖。""" + _, factory, store = installation_store + before = _identity() + with factory() as session: + session.add(_identity_model(before)) + session.commit() + store.create( + _store_record( + transaction_id="install-drift", + identity_before_revision=before.revision, + ) + ) + target = replace(before, revision=2, updated_at=NOW.replace(second=1)) + store.set_target( + "install-drift", + membership_target=True, + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + _set_config(factory, ["DemoPlugin"]) + with pytest.raises(PluginInstallationConflictError, match="membership"): + store.commit_target( + "install-drift", + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) + assert store.get("install-drift").phase is PluginInstallationPhase.PREPARED + + _set_config(factory, []) + _set_identity_revision(factory, 3) + with pytest.raises(PluginInstallationConflictError, match="revision"): + store.commit_target( + "install-drift", + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + +def _commit_or_conflict(store, transaction_id: str) -> str: + """把 phase CAS 竞争转换为可断言的测试结果。""" + try: + store.commit_target( + transaction_id, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + except PluginInstallationConflictError: + return "conflict" + return "committed" + + +def test_store_serializes_membership_commits_and_phase_cas(installation_store) -> None: + """SQLite 下不同插件并发提交应合并,重复提交同一事务只能失败。""" + _, factory, store = installation_store + first = _store_record(transaction_id="install-first") + second = _store_record(transaction_id="install-second", plugin_id="OtherPlugin") + store.create(first) + store.create(second) + store.set_target( + first.transaction_id, + membership_target=True, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + store.set_target( + second.transaction_id, + membership_target=True, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + def commit(record_id: str): + return store.commit_target( + record_id, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list( + executor.map(commit, [first.transaction_id, second.transaction_id]) + ) + assert {result.phase for result in results} == { + PluginInstallationPhase.COMMITTED, + } + assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"} + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list( + executor.map( + lambda _: _commit_or_conflict(store, first.transaction_id), + range(2), + ) + ) + assert outcomes == ["conflict", "conflict"] + + +def test_store_delete_is_idempotent_after_recovery(installation_store) -> None: + """恢复处理重复清理同一 journal 时不产生第二次副作用。""" + _, _, store = installation_store + store.create(_store_record(transaction_id="install-delete")) + + assert store.delete( + "install-delete", + expected_phase=PluginInstallationPhase.PREPARED, + ) is True + assert store.delete( + "install-delete", + expected_phase=PluginInstallationPhase.PREPARED, + ) is False + + +@pytest.fixture +def postgresql_installation_stores(): + """创建两个不共享进程锁的 PostgreSQL Store,验证数据库并发合同。""" + prefix = "MOVIEPILOT_TEST_POSTGRESQL_" + host = os.getenv(f"{prefix}HOST") + database = os.getenv(f"{prefix}DATABASE") + username = os.getenv(f"{prefix}USERNAME") + if not host or not database or not username: + pytest.skip("未配置隔离 PostgreSQL transaction 测试库") + + port = os.getenv(f"{prefix}PORT", "5432") + password = os.getenv(f"{prefix}PASSWORD", "") + schema = f"plugin_transaction_{uuid.uuid4().hex}" + with postgres_driver.connect( + host=host, + port=port, + dbname=database, + user=username, + password=password, + ) as connection: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)) + ) + + engine = sa.create_engine( + sa.URL.create( + POSTGRESQL_DIALECT, + username=username, + password=password, + host=host, + port=int(port), + database=database, + ), + connect_args={"options": f"-csearch_path={schema}"}, + ) + SystemConfig.__table__.create(engine) + with engine.begin() as connection: + _upgrade_migration( + connection, + "database.versions.d2e4f6a8b0c1_3_0_9", + ) + _upgrade_migration( + connection, + "database.versions.e4f7a1b2c3d5_3_0_10", + ) + factory = sessionmaker(bind=engine, expire_on_commit=False) + _set_config(factory, []) + first = TransactionalPluginInstallationStore( + factory, + _AtomicSystemConfig(factory).update_atomically, + ) + second = TransactionalPluginInstallationStore( + factory, + _AtomicSystemConfig(factory).update_atomically, + ) + try: + yield factory, first, second + finally: + engine.dispose() + with postgres_driver.connect( + host=host, + port=port, + dbname=database, + user=username, + password=password, + ) as connection: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format( + sql.Identifier(schema) + ) + ) + + +def test_postgresql_store_serializes_membership_phase_and_revision_cas( + postgresql_installation_stores, +) -> None: + """PostgreSQL 行锁必须合并不同插件写入并拒绝 phase/revision 竞争。""" + factory, first_store, second_store = postgresql_installation_stores + first = _store_record(transaction_id="postgres-first") + second = _store_record( + transaction_id="postgres-second", + plugin_id="OtherPlugin", + ) + for store, record in ((first_store, first), (second_store, second)): + store.create(record) + store.set_target( + record.transaction_id, + membership_target=True, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list( + executor.map( + lambda item: item[0].commit_target( + item[1].transaction_id, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ), + ((first_store, first), (second_store, second)), + ) + ) + + assert {result.phase for result in results} == { + PluginInstallationPhase.COMMITTED, + } + assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"} + + race = _store_record( + transaction_id="postgres-phase-race", + plugin_id="RacePlugin", + ) + first_store.create(race) + first_store.set_target( + race.transaction_id, + membership_target=True, + identity_target=None, + expected_phase=PluginInstallationPhase.PREPARED, + ) + barrier = threading.Barrier(2) + + def commit_race(store) -> str: + barrier.wait() + return _commit_or_conflict(store, race.transaction_id) + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(commit_race, (first_store, second_store))) + assert sorted(outcomes) == ["committed", "conflict"] + + before = _identity(plugin_id="RevisionPlugin") + with factory() as session: + session.add(_identity_model(before)) + session.commit() + revision = _store_record( + transaction_id="postgres-revision", + plugin_id=before.plugin_id, + identity_before_revision=before.revision, + ) + first_store.create(revision) + target = replace( + before, + revision=2, + updated_at=NOW.replace(second=1), + ) + first_store.set_target( + revision.transaction_id, + membership_target=True, + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) + _set_identity_revision(factory, 3, plugin_id=before.plugin_id) + + with pytest.raises(PluginInstallationConflictError, match="revision"): + second_store.commit_target( + revision.transaction_id, + identity_target=target, + expected_phase=PluginInstallationPhase.PREPARED, + ) diff --git a/tests/test_plugin_installation_migration.py b/tests/test_plugin_installation_migration.py new file mode 100644 index 000000000..9a4dc4c11 --- /dev/null +++ b/tests/test_plugin_installation_migration.py @@ -0,0 +1,140 @@ +"""插件安装事务表 Alembic 迁移测试。""" + +import importlib +import os +import uuid + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +try: + import psycopg2 as postgres_driver + from psycopg2 import sql + + POSTGRESQL_DIALECT = "postgresql+psycopg2" +except ModuleNotFoundError: + import psycopg as postgres_driver + from psycopg import sql + + POSTGRESQL_DIALECT = "postgresql+psycopg" + +from app.db.models.plugininstallation import PluginInstallation + +MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10" + + +def _bind_migration(monkeypatch, connection): + """把迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(MIGRATION) + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + return migration + + +def test_plugin_installation_migration_upgrade_downgrade_reupgrade( + monkeypatch, +) -> None: + """SQLite 应支持重复升级、回滚和再次升级,字段与 ORM 保持一致。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + inspector = sa.inspect(connection) + assert "plugininstallation" in inspector.get_table_names() + assert { + column["name"] for column in inspector.get_columns("plugininstallation") + } == {column.name for column in PluginInstallation.__table__.columns} + assert { + index["name"] for index in inspector.get_indexes("plugininstallation") + } == { + "ix_plugininstallation_plugin_id", + "ix_plugininstallation_phase", + } + + migration.downgrade() + assert "plugininstallation" not in sa.inspect(connection).get_table_names() + + migration.upgrade() + assert "plugininstallation" in sa.inspect(connection).get_table_names() + + +def test_plugin_installation_migration_runs_on_postgresql(monkeypatch) -> None: + """隔离 PostgreSQL 应真实执行安装事务表的升级、约束和回滚。""" + prefix = "MOVIEPILOT_TEST_POSTGRESQL_" + host = os.getenv(f"{prefix}HOST") + database = os.getenv(f"{prefix}DATABASE") + username = os.getenv(f"{prefix}USERNAME") + if not host or not database or not username: + pytest.skip("未配置隔离 PostgreSQL migration 测试库") + + port = os.getenv(f"{prefix}PORT", "5432") + password = os.getenv(f"{prefix}PASSWORD", "") + schema = f"plugin_installation_{uuid.uuid4().hex}" + with postgres_driver.connect( + host=host, + port=port, + dbname=database, + user=username, + password=password, + ) as connection: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)) + ) + + engine = None + try: + engine = sa.create_engine( + sa.URL.create( + POSTGRESQL_DIALECT, + username=username, + password=password, + host=host, + port=int(port), + database=database, + ), + connect_args={"options": f"-csearch_path={schema}"}, + ) + with engine.begin() as connection: + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + migration.upgrade() + + inspector = sa.inspect(connection) + assert "plugininstallation" in inspector.get_table_names() + constraints = { + constraint["name"] + for constraint in inspector.get_unique_constraints( + "plugininstallation" + ) + } + assert "uq_plugininstallation_transaction_id" in constraints + + migration.downgrade() + assert "plugininstallation" not in sa.inspect(connection).get_table_names() + finally: + if engine is not None: + engine.dispose() + with postgres_driver.connect( + host=host, + port=port, + dbname=database, + user=username, + password=password, + ) as connection: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format( + sql.Identifier(schema) + ) + ) diff --git a/tests/test_plugin_installation_recovery.py b/tests/test_plugin_installation_recovery.py new file mode 100644 index 000000000..acd2ebd09 --- /dev/null +++ b/tests/test_plugin_installation_recovery.py @@ -0,0 +1,289 @@ +"""插件安装 journal 启动重放与阻断边界测试。""" + +from dataclasses import replace +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.recovery import ( + PluginInstallationRecoveryError, + PluginInstallationRecoveryService, +) +from app.application.plugin.transaction import ( + PluginInstallationPhase, + PluginInstallationRecord, +) + +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc) +RECEIPT = "sha256:" + "1" * 64 + + +def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity: + """构造一份已提交载荷身份。""" + return PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key="github:jxxghp/moviepilot-plugins", + declared_version="2.0.0", + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=True, + payload_receipt=receipt, + revision=revision, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + + +def _record( + *, + phase: PluginInstallationPhase, + transaction_id: str = "txn-demo", +) -> PluginInstallationRecord: + """构造 PREPARED 或 COMMITTED 恢复记录。""" + committed = phase is PluginInstallationPhase.COMMITTED + return PluginInstallationRecord( + transaction_id=transaction_id, + plugin_id="DemoPlugin", + phase=phase, + membership_before=True, + membership_target=True if committed else None, + identity_before_revision=1, + identity_target_revision=2 if committed else None, + package_existed=True, + persistent_backup_existed=True, + created_at=NOW, + updated_at=NOW, + ) + + +class _Persistence: + """保存恢复测试所需 journal、身份和删除故障。""" + + def __init__( + self, + records: list[PluginInstallationRecord], + *, + identity: PluginIdentity | None = None, + delete_errors: list[Exception | None] | None = None, + ) -> None: + self.records = {record.transaction_id: record for record in records} + self.identity = identity + self.delete_errors = list(delete_errors or []) + self.delete_calls: list[tuple[str, PluginInstallationPhase]] = [] + + async def list_installations(self) -> list[PluginInstallationRecord]: + """按创建顺序返回当前 journal。""" + return list(self.records.values()) + + async def get_identity(self, _plugin_id: str) -> PluginIdentity | None: + """返回已提交身份。""" + return self.identity + + async def delete_installation( + self, + transaction_id: str, + *, + expected_phase: PluginInstallationPhase, + ) -> bool: + """按 phase 删除 journal,并可注入一次性错误。""" + self.delete_calls.append((transaction_id, expected_phase)) + if self.delete_errors: + error = self.delete_errors.pop(0) + if error is not None: + raise error + record = self.records.get(transaction_id) + if record is None: + return False + assert record.phase is expected_phase + del self.records[transaction_id] + return True + + +def _packages(**overrides): + """构造恢复服务消费的单一包事务端口。""" + checkpoint = SimpleNamespace( + plugin_existed=True, + persistent_backup_existed=True, + ) + values = { + "restore_checkpoint": Mock(return_value=checkpoint), + "async_restore": AsyncMock(), + "async_cleanup": AsyncMock(), + "async_committed_payload_receipt": AsyncMock(return_value=RECEIPT), + "async_finalize_persistent_backup": AsyncMock(), + "async_commit": AsyncMock(), + } + values.update(overrides) + return SimpleNamespace(**values) + + +@pytest.mark.asyncio +async def test_prepared_replay_restores_before_releasing_journal() -> None: + """PREPARED 必须先恢复旧载荷,再删除 journal 和恢复材料。""" + persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)]) + packages = _packages() + service = PluginInstallationRecoveryService( + persistence=persistence, + packages=packages, + ) + + result = await service.replay() + + assert result.restored == 1 + assert persistence.records == {} + packages.async_restore.assert_awaited_once() + packages.async_cleanup.assert_awaited_once() + assert persistence.delete_calls == [ + ("txn-demo", PluginInstallationPhase.PREPARED) + ] + + +@pytest.mark.asyncio +async def test_prepared_delete_failure_keeps_replayable_journal() -> None: + """恢复完成但 journal 删除失败时,下次启动仍可幂等重放。""" + persistence = _Persistence( + [_record(phase=PluginInstallationPhase.PREPARED)], + delete_errors=[RuntimeError("database unavailable"), None], + ) + packages = _packages() + service = PluginInstallationRecoveryService( + persistence=persistence, + packages=packages, + ) + + with pytest.raises(PluginInstallationRecoveryError, match="未提交安装恢复失败"): + await service.replay() + assert "txn-demo" in persistence.records + packages.async_cleanup.assert_not_awaited() + + result = await service.replay() + + assert result.restored == 1 + assert persistence.records == {} + assert packages.async_restore.await_count == 2 + packages.async_cleanup.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_prepared_restore_failure_blocks_plugin_import() -> None: + """旧载荷无法恢复时必须保留 journal,并让启动阶段失败。""" + persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)]) + packages = _packages( + async_restore=AsyncMock(side_effect=RuntimeError("snapshot missing")) + ) + service = PluginInstallationRecoveryService( + persistence=persistence, + packages=packages, + ) + + with pytest.raises(PluginInstallationRecoveryError, match="snapshot missing"): + await service.replay() + + assert "txn-demo" in persistence.records + assert persistence.delete_calls == [] + packages.async_cleanup.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_committed_replay_verifies_identity_and_receipt_before_cleanup() -> None: + """COMMITTED 只在身份 revision 和载荷收据一致时完成幂等收尾。""" + persistence = _Persistence( + [_record(phase=PluginInstallationPhase.COMMITTED)], + identity=_identity(), + ) + packages = _packages() + service = PluginInstallationRecoveryService( + persistence=persistence, + packages=packages, + ) + + result = await service.replay() + + assert result.finalized == 1 + assert persistence.records == {} + packages.async_committed_payload_receipt.assert_awaited_once() + packages.async_finalize_persistent_backup.assert_awaited_once() + packages.async_commit.assert_awaited_once() + assert persistence.delete_calls == [ + ("txn-demo", PluginInstallationPhase.COMMITTED) + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("identity", "receipt", "message"), + [ + (replace(_identity(), revision=3), RECEIPT, "身份与安装 journal 不一致"), + (_identity(), "sha256:" + "2" * 64, "载荷收据不一致"), + ], +) +async def test_committed_fact_mismatch_blocks_plugin_import( + identity: PluginIdentity, + receipt: str, + message: str, +) -> None: + """已提交数据库事实与可恢复载荷不一致时不得继续加载插件。""" + persistence = _Persistence( + [_record(phase=PluginInstallationPhase.COMMITTED)], + identity=identity, + ) + packages = _packages( + async_committed_payload_receipt=AsyncMock(return_value=receipt) + ) + service = PluginInstallationRecoveryService( + persistence=persistence, + packages=packages, + ) + + with pytest.raises(PluginInstallationRecoveryError, match=message): + await service.replay() + + assert "txn-demo" in persistence.records + packages.async_finalize_persistent_backup.assert_not_awaited() + packages.async_commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_committed_cleanup_failure_is_retried_without_rollback() -> None: + """COMMITTED 收尾失败只保留 journal,下一次启动继续清理。""" + persistence = _Persistence( + [_record(phase=PluginInstallationPhase.COMMITTED)], + identity=_identity(), + ) + package_commit = AsyncMock( + side_effect=[RuntimeError("snapshot busy"), None] + ) + packages = _packages(async_commit=package_commit) + service = PluginInstallationRecoveryService( + persistence=persistence, + packages=packages, + ) + + first = await service.replay() + + assert first.cleanup_pending == 1 + assert "txn-demo" in persistence.records + assert persistence.delete_calls == [] + + second = await service.replay() + + assert second.finalized == 1 + assert persistence.records == {} + assert packages.async_finalize_persistent_backup.await_count == 2 + assert package_commit.await_count == 2 diff --git a/tests/test_plugin_lifecycle_coordinator.py b/tests/test_plugin_lifecycle_coordinator.py new file mode 100644 index 000000000..3d603a7a8 --- /dev/null +++ b/tests/test_plugin_lifecycle_coordinator.py @@ -0,0 +1,168 @@ +"""插件生命周期协调器的启动 owner/token 契约测试。""" + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress + +import pytest + +from app.application.plugin.lifecycle import PluginLifecycleCoordinator + + +async def _assert_event_waits(event: asyncio.Event) -> None: + """确认事件在短预算内仍未发生,避免测试依赖固定 sleep 时序。""" + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.shield(event.wait()), timeout=0.03) + + +async def _cancel_task(task: asyncio.Task) -> None: + """取消仍在等待生命周期资格的任务并消费其终态。""" + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_startup_scope_yields_opaque_token_for_matching_plugin_hold() -> None: + """启动 owner 取得的 token 可让内部取得逐插件资格。""" + coordinator = PluginLifecycleCoordinator() + + async with coordinator.hold_startup() as startup_token: + assert startup_token is not None + assert not isinstance(startup_token, (str, bytes, int, bool)) + + entered = asyncio.Event() + release = asyncio.Event() + + async def hold_plugin() -> None: + async with coordinator.hold("DemoPlugin", startup_token): + entered.set() + await release.wait() + + task = asyncio.create_task(hold_plugin()) + await entered.wait() + assert coordinator._active_plugins == {"demoplugin"} + release.set() + await task + + assert coordinator._active_plugins == set() + + +@pytest.mark.asyncio +async def test_external_and_duplicate_plugin_holds_remain_blocked() -> None: + """启动内部的逐插件资格不向外部调用放行,且同插件仍保持互斥。""" + coordinator = PluginLifecycleCoordinator() + internal_release = asyncio.Event() + duplicate_release = asyncio.Event() + internal_entered = asyncio.Event() + duplicate_entered = asyncio.Event() + external_entered = asyncio.Event() + + async with coordinator.hold_startup() as startup_token: + + async def internal_hold() -> None: + async with coordinator.hold("DemoPlugin", startup_token): + internal_entered.set() + await internal_release.wait() + + async def duplicate_hold() -> None: + async with coordinator.hold("demoplugin", startup_token): + duplicate_entered.set() + await duplicate_release.wait() + + async def external_hold() -> None: + async with coordinator.hold("DemoPlugin"): + external_entered.set() + + internal_task = asyncio.create_task(internal_hold()) + await internal_entered.wait() + duplicate_task = asyncio.create_task(duplicate_hold()) + external_task = asyncio.create_task(external_hold()) + + await _assert_event_waits(duplicate_entered) + await _assert_event_waits(external_entered) + + internal_release.set() + await internal_task + await duplicate_entered.wait() + await _assert_event_waits(external_entered) + duplicate_release.set() + await duplicate_task + + await external_entered.wait() + await external_task + + +@pytest.mark.asyncio +async def test_foreign_and_expired_tokens_cannot_bypass_current_startup_lease() -> None: + """其他 coordinator 或旧 lease 的 token 不得绕过当前启动 owner。""" + first = PluginLifecycleCoordinator() + second = PluginLifecycleCoordinator() + + async with first.hold_startup() as foreign_token: + async with second.hold_startup() as current_token: + assert foreign_token is not current_token + foreign_entered = asyncio.Event() + + async def foreign_hold() -> None: + async with second.hold("DemoPlugin", foreign_token): + foreign_entered.set() + + foreign_task = asyncio.create_task(foreign_hold()) + await _assert_event_waits(foreign_entered) + await _cancel_task(foreign_task) + + async with second.hold_startup() as expired_token: + pass + + async with second.hold_startup() as current_token: + assert expired_token is not current_token + expired_entered = asyncio.Event() + + async def expired_hold() -> None: + async with second.hold("DemoPlugin", expired_token): + expired_entered.set() + + expired_task = asyncio.create_task(expired_hold()) + await _assert_event_waits(expired_entered) + await _cancel_task(expired_task) + + +@pytest.mark.asyncio +async def test_startup_token_can_cross_threads_without_contextvar() -> None: + """显式 token 可跨线程传递,资格判断不依赖隐式 ContextVar。""" + coordinator = PluginLifecycleCoordinator() + main_thread = threading.current_thread().name + + def run_in_thread(startup_token: object) -> tuple[str, set[str]]: + async def hold_plugin() -> tuple[str, set[str]]: + async with coordinator.hold("DemoPlugin", startup_token): + return threading.current_thread().name, set(coordinator._active_plugins) + + return asyncio.run(hold_plugin()) + + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-startup") as executor: + async with coordinator.hold_startup() as startup_token: + result = await asyncio.wrap_future( + executor.submit(run_in_thread, startup_token) + ) + + assert result[0] != main_thread + assert result[1] == {"demoplugin"} + + +@pytest.mark.asyncio +async def test_hold_without_token_retains_startup_waiting_compatibility() -> None: + """无参数调用继续遵守启动全局资格的等待语义。""" + coordinator = PluginLifecycleCoordinator() + async with coordinator.hold_startup(): + entered = asyncio.Event() + + async def external_hold() -> None: + async with coordinator.hold("DemoPlugin"): + entered.set() + + task = asyncio.create_task(external_hold()) + await _assert_event_waits(entered) + await _cancel_task(task) diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index e863e5d88..83ea85e95 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -8,17 +8,40 @@ import pytest from packaging.version import Version from watchfiles import Change +from app.adapters.external.market import PluginHelper +from app.foundation.singleton import Singleton from app.runtime.events import Event, eventmanager from app.runtime.extensions.plugin_manager import PluginManager -from app.adapters.external.market import PluginHelper +from app.runtime.extensions.plugin.system import get_plugin_system from app.scheduler import Scheduler from app.schemas.types import EventType, SystemConfigKey -from app.foundation.singleton import Singleton @pytest.fixture -def plugin_manager() -> Iterator[PluginManager]: +def plugin_manager(monkeypatch) -> Iterator[PluginManager]: """构造隔离的插件管理器实例,避免单例状态污染其它用例。""" + system = get_plugin_system() + + def install_local(**kwargs) -> tuple[bool, str]: + """用测试包适配器模拟已通过来源准入的本地 Gateway。""" + repo_url = kwargs["repo_url"] + candidate = system.local_candidate( + kwargs["plugin_id"], + package_version=kwargs.get("package_version"), + repo_path=PluginHelper.parse_local_repo_path(repo_url), + strict_system_version=False, + ) + if not candidate: + return False, "本地候选不存在" + return ( + system.package.sync_local( + kwargs["plugin_id"], + Path(candidate["path"]), + ), + "", + ) + + monkeypatch.setattr(system, "install", install_local) Singleton._instances.pop((PluginManager, (), frozenset()), None) manager = PluginManager() yield manager @@ -65,6 +88,7 @@ def _configure_local_watcher( PLUGIN_LOCAL_REPO_PATHS=str(repo_path), ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp", + CONFIG_PATH=tmp_path / "config", VERSION_FLAG="v2", ) monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) @@ -143,6 +167,7 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l DEV=True, ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp", + CONFIG_PATH=tmp_path / "config", ) monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub) monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub) diff --git a/tests/test_plugin_market_index_policy.py b/tests/test_plugin_market_index_policy.py index f043fdace..b75398fc5 100644 --- a/tests/test_plugin_market_index_policy.py +++ b/tests/test_plugin_market_index_policy.py @@ -5,6 +5,7 @@ from types import SimpleNamespace import pytest from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin.client import PluginMarketClient @pytest.mark.asyncio @@ -78,3 +79,113 @@ def test_plugin_index_response_preserves_status_contract( result = PluginHelper._resolve_plugin_index_response(status_code, content) assert result == expected + + +@pytest.mark.parametrize( + ("status_code", "content", "expected"), + [ + (200, '{"DemoPlugin": {"version": "1.2.3"}}', {"DemoPlugin": {"version": "1.2.3"}}), + (404, "404: Not Found", None), + ], +) +def test_plugin_index_result_preserves_read_state( + monkeypatch, + status_code: int, + content: str, + expected: dict | None, +) -> None: + """只读入口以值和 None 区分真实索引与确定不存在。""" + helper = PluginHelper() + repo_url = f"https://github.com/policy-owner/policy-repository-{status_code}" + + def request(_url: str, *, headers: dict): + return SimpleNamespace(status_code=status_code, text=content) + + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request) + helper.get_plugin_index_result.cache_clear() + + result = helper.get_plugin_index_result(repo_url, "v3") + + assert result == expected + + +@pytest.mark.parametrize( + ("status_code", "content", "message"), + [ + (500, "upstream failed", "插件索引请求失败:HTTP 500"), + (200, "not-json", "插件索引响应格式无效"), + ], +) +def test_plugin_index_result_raises_for_unusable_reads( + monkeypatch, + status_code: int, + content: str, + message: str, +) -> None: + """不可判定读取必须抛错,由应用库存统一记录失败事实。""" + helper = PluginHelper() + + def request(_url: str, *, headers: dict): + return SimpleNamespace(status_code=status_code, text=content) + + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request) + helper.get_plugin_index_result.cache_clear() + + with pytest.raises(RuntimeError, match=message): + helper.get_plugin_index_result( + f"https://github.com/policy-owner/policy-failed-{status_code}", + "v3", + ) + + +@pytest.mark.asyncio +async def test_async_plugin_index_result_preserves_absent_state(monkeypatch) -> None: + """异步只读入口也必须保留 404 不存在事实。""" + helper = PluginHelper() + + async def request(_url: str, *, headers: dict): + return SimpleNamespace(status_code=404, text="404: Not Found") + + monkeypatch.setattr( + helper, + "_PluginHelper__async_request_with_fallback", + request, + ) + await helper.async_get_plugin_index_result.cache_clear() + + result = await helper.async_get_plugin_index_result( + "https://github.com/policy-owner/policy-repository-async", + "v3", + ) + + assert result is None + + +def test_plugin_index_result_propagates_adapter_exception(monkeypatch) -> None: + """请求异常必须传播给应用库存统一转换为失败事实。""" + helper = PluginHelper() + + def request(_url: str, *, headers: dict): + raise OSError("socket closed") + + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request) + helper.get_plugin_index_result.cache_clear() + + with pytest.raises(OSError, match="socket closed"): + helper.get_plugin_index_result( + "https://github.com/policy-owner/policy-repository-exception", + "v3", + ) + + +def test_plugin_market_client_exposes_index_result_port() -> None: + """市场客户端应原样转发索引读取结果并保留只读边界。""" + expected = {"DemoPlugin": {"version": "1.2.3"}} + + class FakeHelper: + def get_plugin_index_result(self, repo_url: str, package_version: str | None): + return expected + + client = PluginMarketClient(FakeHelper()) + + assert client.get_plugin_index_result("https://github.com/example/repo", "v3") is expected diff --git a/tests/test_plugin_monitor_lifecycle.py b/tests/test_plugin_monitor_lifecycle.py index fa91e5e1e..3b3b6564e 100644 --- a/tests/test_plugin_monitor_lifecycle.py +++ b/tests/test_plugin_monitor_lifecycle.py @@ -154,6 +154,30 @@ def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock: asyncio.get_running_loop(), ) monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None) + migration = MagicMock() + migration.migrate = AsyncMock() + monkeypatch.setattr( + plugins_initializer, + "get_plugin_identity_migration", + lambda: migration, + ) + config = MagicMock() + config.get.return_value = [] + monkeypatch.setattr( + plugins_initializer, + "get_configured_system_config", + lambda: config, + ) + monkeypatch.setattr( + plugins_initializer, + "get_plugin_persistence", + MagicMock, + ) + monkeypatch.setattr( + plugins_initializer, + "_collect_online_restore_plugins", + AsyncMock(return_value=set()), + ) monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) monkeypatch.setattr(plugins_initializer, "execute_task", execute) monkeypatch.setattr(plugins_initializer, "register_plugin_api", register) @@ -347,6 +371,30 @@ async def test_sync_plugins_keeps_event_loop_responsive_during_activation( return_value=PluginDependencyInstallResult(missing=[], success=True), ) monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None) + migration = MagicMock() + migration.migrate = AsyncMock() + monkeypatch.setattr( + plugins_initializer, + "get_plugin_identity_migration", + lambda: migration, + ) + config = MagicMock() + config.get.return_value = [] + monkeypatch.setattr( + plugins_initializer, + "get_configured_system_config", + lambda: config, + ) + monkeypatch.setattr( + plugins_initializer, + "get_plugin_persistence", + MagicMock, + ) + monkeypatch.setattr( + plugins_initializer, + "_collect_online_restore_plugins", + AsyncMock(return_value=set()), + ) monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager) monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock()) monkeypatch.setattr( diff --git a/tests/test_plugin_package_manager.py b/tests/test_plugin_package_manager.py index ad48f91c4..848971190 100644 --- a/tests/test_plugin_package_manager.py +++ b/tests/test_plugin_package_manager.py @@ -12,7 +12,11 @@ def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager: """构造使用隔离运行目录和事务目录的插件包管理器。""" monkeypatch.setattr( "app.adapters.system.plugin.package.settings", - SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"), + SimpleNamespace( + ROOT_PATH=tmp_path, + TEMP_PATH=tmp_path / "temp", + CONFIG_PATH=tmp_path / "config", + ), ) return PluginPackageManager(helper=Mock()) @@ -66,6 +70,145 @@ def test_rollback_does_not_delete_package_when_snapshot_is_missing(monkeypatch, assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "new" +def test_durable_checkpoint_stages_backup_without_overwriting_current_backup( + monkeypatch, + tmp_path, +): + """数据库提交前只准备新备份,现有容器恢复材料保持可用。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr( + "app.adapters.system.plugin.package.SystemUtils.is_docker", + lambda: True, + ) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin" + plugin_dir.mkdir(parents=True) + backup_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("new", encoding="utf-8") + (backup_dir / "__init__.py").write_text("old", encoding="utf-8") + + checkpoint = manager.checkpoint("DemoPlugin", "txn-1") + manager.stage_persistent_backup(checkpoint) + + assert checkpoint.transaction_dir.parent == tmp_path / "config" / "plugin_transactions" + assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old" + assert checkpoint.backup_staging_dir is not None + assert (checkpoint.backup_staging_dir / "__init__.py").read_text( + encoding="utf-8" + ) == "new" + + +def test_activate_and_finalize_persistent_backup_are_retryable(monkeypatch, tmp_path): + """备份激活保留旧载荷,数据库提交后的清理可以重复执行。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr( + "app.adapters.system.plugin.package.SystemUtils.is_docker", + lambda: True, + ) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin" + plugin_dir.mkdir(parents=True) + backup_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("new", encoding="utf-8") + (backup_dir / "__init__.py").write_text("old", encoding="utf-8") + checkpoint = manager.checkpoint("DemoPlugin", "txn-2") + manager.stage_persistent_backup(checkpoint) + + manager.activate_persistent_backup(checkpoint) + manager.activate_persistent_backup(checkpoint) + + assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "new" + assert checkpoint.backup_staging_dir is not None + assert not checkpoint.backup_staging_dir.exists() + assert checkpoint.backup_previous_dir is not None + assert (checkpoint.backup_previous_dir / "__init__.py").read_text( + encoding="utf-8" + ) == "old" + + manager.finalize_persistent_backup(checkpoint) + manager.finalize_persistent_backup(checkpoint) + + assert not checkpoint.backup_previous_dir.exists() + + +def test_rollback_removes_staging_but_preserves_current_backup(monkeypatch, tmp_path): + """提交前失败只恢复运行目录,不修改上一份容器恢复备份。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr( + "app.adapters.system.plugin.package.SystemUtils.is_docker", + lambda: True, + ) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin" + plugin_dir.mkdir(parents=True) + backup_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8") + (backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8") + checkpoint = manager.checkpoint("DemoPlugin", "txn-3") + (plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8") + manager.stage_persistent_backup(checkpoint) + + manager.rollback(checkpoint) + + assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime" + assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup" + assert checkpoint.backup_staging_dir is not None + assert not checkpoint.backup_staging_dir.exists() + + +def test_rollback_after_backup_activation_restores_previous_backup( + monkeypatch, + tmp_path, +): + """数据库提交前失败时,已激活的新备份必须回退到上一份载荷。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr( + "app.adapters.system.plugin.package.SystemUtils.is_docker", + lambda: True, + ) + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin" + plugin_dir.mkdir(parents=True) + backup_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8") + (backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8") + checkpoint = manager.checkpoint("DemoPlugin", "txn-4") + (plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8") + manager.stage_persistent_backup(checkpoint) + manager.activate_persistent_backup(checkpoint) + + manager.rollback(checkpoint) + + assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime" + assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup" + + +def test_restore_checkpoint_derives_only_controlled_paths(monkeypatch, tmp_path): + """崩溃回放只按事务 ID 在受控根目录内重建文件引用。""" + manager = _manager(monkeypatch, tmp_path) + monkeypatch.setattr( + "app.adapters.system.plugin.package.SystemUtils.is_docker", + lambda: True, + ) + + checkpoint = manager.restore_checkpoint( + plugin_id="DemoPlugin", + transaction_id="txn-5", + plugin_existed=True, + persistent_backup_existed=False, + ) + + assert checkpoint.transaction_dir == ( + tmp_path / "config" / "plugin_transactions" / "txn-5" + ) + assert checkpoint.backup_staging_dir == ( + tmp_path / "config" / "plugins_backup" / ".demoplugin.staging-txn-5" + ) + assert checkpoint.backup_previous_dir == ( + tmp_path / "config" / "plugins_backup" / ".demoplugin.previous-txn-5" + ) + + def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path): """本地来源不可复制时不得丢失已经运行的插件副本。""" manager = _manager(monkeypatch, tmp_path) diff --git a/tests/test_plugin_settlement_lifecycle.py b/tests/test_plugin_settlement_lifecycle.py index 28ffd86bd..dcecde736 100644 --- a/tests/test_plugin_settlement_lifecycle.py +++ b/tests/test_plugin_settlement_lifecycle.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from app.application.plugin.lifecycle import PluginStartupLease from app.runtime.config import global_vars from app.startup import lifecycle @@ -12,10 +13,12 @@ from app.startup import lifecycle async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch) -> None: """插件 ready 只在调度任务和命令注册完成后对外可见。""" order: list[str] = [] + startup_tokens: list[PluginStartupLease] = [] manager = MagicMock() command_future = Future() - async def sync_plugins() -> bool: + async def sync_plugins(startup_token: PluginStartupLease) -> bool: + startup_tokens.append(startup_token) order.append("plugins") return True @@ -70,3 +73,5 @@ async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch "settling:False", "monitor", ] + assert len(startup_tokens) == 1 + assert isinstance(startup_tokens[0], PluginStartupLease) diff --git a/tests/test_plugin_source_policy.py b/tests/test_plugin_source_policy.py new file mode 100644 index 000000000..c7ee9faa7 --- /dev/null +++ b/tests/test_plugin_source_policy.py @@ -0,0 +1,299 @@ +"""插件候选事实与来源选择策略测试。""" + +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.source import ( + CandidateInventory, + LocalCandidateRead, + MarketRead, + PluginLocalCandidate, + PluginMarketCandidate, + PluginSelectionStatus, + select_plugin_candidate, +) + +OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins" +THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins" +OTHER_SOURCE = "github:other/moviepilot-plugins" + + +def _online( + source_key: str, + *, + source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY, + version: str = "1.0.0", + generation: str = "v3", + plugin_id: str = "DemoPlugin", + repo_url: str = "https://github.com/example/moviepilot-plugins", +) -> PluginMarketCandidate: + """构造测试用在线候选。""" + return PluginMarketCandidate( + plugin_id=plugin_id, + source_key=source_key, + source_type=source_type, + repo_url=repo_url, + package_generation=generation, + plugin_version=version, + dto={"id": plugin_id, "version": version}, + ) + + +def _inventory(*reads: MarketRead, local=()) -> CandidateInventory: + """构造测试用候选快照。""" + return CandidateInventory(tuple(reads), tuple(local)) + + +def _identity(source_type: TrustedPluginSourceType, source_key: str) -> PluginIdentity: + """构造已绑定在线来源身份。""" + from datetime import datetime, timezone + + now = datetime(2026, 8, 25, tzinfo=timezone.utc) + return PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=source_type, + trusted_source_key=source_key, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT + if source_type is TrustedPluginSourceType.OFFICIAL + else PluginBindingBasis.TOFU, + payload_source_type=PluginPayloadSourceType.UNKNOWN, + payload_source_key=None, + declared_version=None, + package_generation=None, + system_version=None, + supports_v3=None, + supports_v3t=None, + payload_receipt=None, + revision=1, + created_at=now, + updated_at=now, + bound_at=now, + payload_applied_at=None, + ) + + +def test_cross_source_high_version_does_not_win() -> None: + """已绑定来源过滤必须先于版本比较,跨源高版本不能覆盖允许来源。""" + inventory = _inventory( + MarketRead.present( + "market-a", + ( + _online(THIRD_PARTY_SOURCE, version="1.0.0"), + _online(OTHER_SOURCE, version="9.0.0", repo_url="https://github.com/other/moviepilot-plugins"), + ), + ), + ) + + result = select_plugin_candidate( + inventory, + plugin_id="DemoPlugin", + identity=_identity(TrustedPluginSourceType.THIRD_PARTY, THIRD_PARTY_SOURCE), + generations=("v3", "v2", "v1"), + ) + + assert result.status is PluginSelectionStatus.SELECTED + assert result.candidate is not None + assert result.candidate.source_key == THIRD_PARTY_SOURCE + assert result.candidate.plugin_version == "1.0.0" + + +def test_same_source_prefers_generation_then_version() -> None: + """同源候选先按运行代际,再在同代内按声明版本选择。""" + inventory = _inventory( + MarketRead.present( + "market-a", + ( + _online(THIRD_PARTY_SOURCE, generation="v2", version="9.0.0"), + _online(THIRD_PARTY_SOURCE, generation="v3", version="1.0.0"), + _online(THIRD_PARTY_SOURCE, generation="v3", version="2.0.0"), + ), + ), + ) + + result = select_plugin_candidate( + inventory, + plugin_id="DemoPlugin", + identity=_identity(TrustedPluginSourceType.THIRD_PARTY, THIRD_PARTY_SOURCE), + generations=("v3", "v2", "v1"), + ) + + assert len(inventory.candidates_for("demoplugin")) == 3 + assert result.candidate is not None + assert result.candidate.package_generation == "v3" + assert result.candidate.plugin_version == "2.0.0" + + +def test_partial_market_failure_blocks_unique_third_party_tofu() -> None: + """部分市场失败时即使当前可见一个第三方,也不能证明其唯一。""" + inventory = _inventory( + MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)), + MarketRead.failure("market-b", "timeout"), + ) + + result = select_plugin_candidate( + inventory, + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + ) + + assert inventory.complete is False + assert inventory.can_use_for_tofu is False + assert result.status is PluginSelectionStatus.INCOMPLETE + + +def test_partial_inventory_expectations_never_authorize_tofu() -> None: + """缺少任一预期维度时,快照不能证明第三方来源唯一。""" + reads = (MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),) + markets_only = CandidateInventory( + reads, + expected_markets=("market-a", "market-b"), + ) + generations_only = CandidateInventory( + reads, + expected_generations=("v3",), + ) + + assert markets_only.complete is False + assert markets_only.can_use_for_tofu is False + assert generations_only.complete is False + assert generations_only.can_use_for_tofu is False + + +def test_local_scan_failure_blocks_automatic_selection_but_explicit_source_continues() -> None: + """本地扫描失败时自动路径闭锁,管理员明确选在线来源仍可继续。""" + inventory = CandidateInventory( + ( + MarketRead.present( + "market-a", + (_online(THIRD_PARTY_SOURCE),), + ), + ), + local_read=LocalCandidateRead.failure("local repository unavailable"), + ) + + automatic = select_plugin_candidate( + inventory, + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + ) + explicit = select_plugin_candidate( + inventory, + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + requested_source_key=THIRD_PARTY_SOURCE, + explicit_source=True, + ) + + assert automatic.status is PluginSelectionStatus.INCOMPLETE + assert explicit.status is PluginSelectionStatus.SELECTED + assert explicit.candidate is not None + assert explicit.candidate.source_key == THIRD_PARTY_SOURCE + + +def test_non_explicit_source_hint_cannot_bypass_local_state() -> None: + """兼容来源参数不能替换本地载荷,也不能绕过本地读取失败闭锁。""" + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?path=/private/plugins", + package_generation="v3", + plugin_version="2.0.0-dev", + ) + identity = _identity( + TrustedPluginSourceType.THIRD_PARTY, + THIRD_PARTY_SOURCE, + ) + with_local = select_plugin_candidate( + _inventory( + MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)), + local=(local,), + ), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + identity=identity, + requested_source_key=THIRD_PARTY_SOURCE, + explicit_source=False, + ) + failed_local_read = select_plugin_candidate( + CandidateInventory( + (MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),), + local_read=LocalCandidateRead.failure("local repository unavailable"), + ), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + identity=identity, + requested_source_key=THIRD_PARTY_SOURCE, + explicit_source=False, + ) + + assert with_local.status is PluginSelectionStatus.SELECTED + assert with_local.candidate is local + assert failed_local_read.status is PluginSelectionStatus.INCOMPLETE + + +def test_uninstalled_unique_and_multiple_sources_are_distinct() -> None: + """未安装插件允许完整快照中的唯一来源,多来源必须返回冲突。""" + unique = select_plugin_candidate( + _inventory(MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),))), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + ) + conflict = select_plugin_candidate( + _inventory( + MarketRead.present( + "market-a", + (_online(THIRD_PARTY_SOURCE), _online(OTHER_SOURCE)), + ), + ), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + ) + + assert unique.status is PluginSelectionStatus.SELECTED + assert conflict.status is PluginSelectionStatus.CONFLICT + assert set(conflict.conflict_source_keys) == {THIRD_PARTY_SOURCE, OTHER_SOURCE} + + +def test_official_candidate_is_selectable_and_local_projection_hides_path() -> None: + """官方来源可正常选择,本地公共投影不能泄漏仓库路径或 metadata。""" + official = select_plugin_candidate( + _inventory( + MarketRead.present( + "official-market", + (_online( + OFFICIAL_SOURCE, + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url="https://github.com/jxxghp/moviepilot-plugins", + ),), + ), + ), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + ) + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?path=/private/secret/plugins", + package_generation="v3", + plugin_version="3.0.0", + dto={"path": "/private/secret/plugins"}, + ) + + local_result = select_plugin_candidate( + _inventory(MarketRead.present("official-market", ()), local=(local,)), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + ) + + assert official.status is PluginSelectionStatus.SELECTED + assert official.candidate is not None + assert official.candidate.source_type is TrustedPluginSourceType.OFFICIAL + assert local.payload_source_type is PluginPayloadSourceType.LOCAL + assert local.source_type is PluginPayloadSourceType.LOCAL + assert local.source_key is None + assert local_result.candidate is local + public = local_result.public_dict() + assert "/private/secret/plugins" not in str(public) + assert "repo_url" not in public["candidate"] diff --git a/tests/test_plugin_sync_service.py b/tests/test_plugin_sync_service.py index 609aa515b..df40cbb89 100644 --- a/tests/test_plugin_sync_service.py +++ b/tests/test_plugin_sync_service.py @@ -1,9 +1,31 @@ """插件市场同步服务用例。""" +import asyncio +from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock +import pytest + +from app.application.plugin.gateway import PluginInstallGateway +from app.application.plugin.identity import ( + PluginBindingBasis, + PluginIdentity, + PluginPayloadSourceType, + TrustedPluginSourceType, +) +from app.application.plugin.install import PluginInstallResult +from app.application.plugin.lifecycle import plugin_lifecycle +from app.application.plugin.source import ( + CandidateInventory, + MarketRead, + PluginMarketCandidate, +) +from app.runtime.config import global_vars from app.runtime.extensions.plugin.sync import PluginSyncService +from app.startup.initializers import plugins as plugins_initializer + +REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins" def test_market_sync_keeps_install_rollback_enabled() -> None: @@ -24,9 +46,191 @@ def test_market_sync_keeps_install_rollback_enabled() -> None: merge_plugins=lambda items, *_args: items, plugin_exists=lambda *_args: False, install=install, - report=Mock(), log=Mock(), ) assert service.sync() == [plugin.id] - install.assert_called_once_with(plugin.id, plugin.repo_url, False) + install.assert_called_once_with(plugin.id, None, False, None) + + +def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None: + """本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。""" + plugin = SimpleNamespace( + id="DemoPlugin", + repo_url=REPO_URL, + plugin_name="Demo", + plugin_version="1.2.0", + system_version_compatible=False, + ) + install = Mock(return_value=(True, "")) + service = PluginSyncService( + frozen=lambda: False, + installed_plugins=lambda: [plugin.id], + online_plugins=lambda: [plugin], + local_plugins=lambda: [], + merge_plugins=lambda items, *_args: items, + plugin_exists=lambda *_args: True, + install=install, + log=Mock(), + ) + + assert service.sync( + online_restore_plugins={"demoplugin"}, + ) == [plugin.id] + install.assert_called_once_with(plugin.id, None, False, None) + + +def test_market_sync_keeps_active_local_payload_when_candidate_still_exists() -> None: + """本地候选仍存在时,不应被启动在线恢复覆盖。""" + online = SimpleNamespace( + id="DemoPlugin", + repo_url=REPO_URL, + plugin_name="Demo", + plugin_version="1.2.0", + system_version_compatible=True, + ) + local = SimpleNamespace( + id="DemoPlugin", + repo_url="local://DemoPlugin?package_version=v3", + plugin_name="Demo Local", + plugin_version="9.9.10", + system_version_compatible=True, + ) + install = Mock(return_value=(True, "")) + service = PluginSyncService( + frozen=lambda: False, + installed_plugins=lambda: [online.id], + online_plugins=lambda: [online], + local_plugins=lambda: [local], + merge_plugins=lambda items, *_args: [online], + plugin_exists=lambda *_args: True, + install=install, + log=Mock(), + ) + + assert service.sync(online_restore_plugins={"demoplugin"}) == [] + install.assert_not_called() + + +@pytest.mark.asyncio +async def test_market_sync_reuses_startup_lease_through_real_gateway( + monkeypatch, +) -> None: + """启动自动安装跨线程进入 Gateway 时必须复用同一个 startup lease。""" + competing_repo_url = "https://github.com/example/MoviePilot-Plugins" + plugin = SimpleNamespace( + id="DemoPlugin", + repo_url=competing_repo_url, + plugin_name="Demo", + plugin_version="9.0.0", + system_version_compatible=True, + ) + official_candidate = PluginMarketCandidate( + plugin_id=plugin.id, + source_key="github:jxxghp/moviepilot-plugins", + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v3", + plugin_version="1.1.0", + dto={"v3": True}, + ) + competing_candidate = PluginMarketCandidate( + plugin_id=plugin.id, + source_key="github:example/moviepilot-plugins", + source_type=TrustedPluginSourceType.THIRD_PARTY, + repo_url=competing_repo_url, + package_generation="v3", + plugin_version=plugin.plugin_version, + dto={"v3": True}, + ) + inventory = CandidateInventory(( + MarketRead.present( + REPO_URL, + (official_candidate,), + package_generation="v3", + ), + MarketRead.present( + competing_repo_url, + (competing_candidate,), + package_generation="v3", + ), + )) + identity = PluginIdentity( + plugin_id=plugin.id, + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.LOCAL, + payload_source_key=None, + declared_version="9.9.10", + package_generation="v3", + system_version=None, + supports_v3=True, + supports_v3t=None, + payload_receipt="sha256:" + "0" * 64, + revision=1, + created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc), + bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc), + payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc), + ) + executor = AsyncMock() + executor.execute.return_value = PluginInstallResult(success=True) + gateway = PluginInstallGateway( + inventory=AsyncMock(return_value=inventory), + identity=AsyncMock(return_value=identity), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc), + ) + monkeypatch.setattr( + global_vars, + "CURRENT_EVENT_LOOP", + asyncio.get_running_loop(), + ) + + def install( + plugin_id: str, + repo_url: str | None, + force: bool, + startup_token: object | None, + ) -> tuple[bool, str]: + """复用生产同步包装层,把线程池安装提交回宿主事件循环。""" + return plugins_initializer._run_plugin_install_sync( + gateway, + plugin_id=plugin_id, + repo_url=repo_url, + package_version="v3", + release_version=None, + force=force, + local_sync=False, + explicit_source=False, + startup_token=startup_token, + ) + + service = PluginSyncService( + frozen=lambda: False, + installed_plugins=lambda: [plugin.id], + online_plugins=lambda: [plugin], + local_plugins=lambda: [], + merge_plugins=lambda items, *_args: items, + plugin_exists=lambda *_args: True, + install=install, + log=Mock(), + ) + + async with plugin_lifecycle.hold_startup() as startup_token: + synced = await asyncio.wait_for( + asyncio.to_thread( + service.sync, + startup_token, + online_restore_plugins={"demoplugin"}, + ), + timeout=2, + ) + + assert synced == [plugin.id] + executor.execute.assert_awaited_once() + admission = executor.execute.await_args.kwargs["admission"] + assert admission.candidate.repo_url == REPO_URL diff --git a/tests/test_systemconfig_oper.py b/tests/test_systemconfig_oper.py index 076ccb2d3..b50eb4694 100644 --- a/tests/test_systemconfig_oper.py +++ b/tests/test_systemconfig_oper.py @@ -117,6 +117,42 @@ def test_failed_write_keeps_committed_snapshot(monkeypatch): assert oper.get(key) == "old" +def test_update_atomically_commits_related_records_and_snapshot() -> None: + """关联记录与最终配置值必须在同一事务成功后一起可见。""" + key = _unique_key() + related_key = _unique_key() + oper = _fresh_oper() + oper.set(key, ["ExistingPlugin"]) + + def mutation(session, current): + session.add(SystemConfig(key=related_key, value={"phase": "committed"})) + return "done", [*current, "DemoPlugin"] + + assert oper.update_atomically(key, mutation) == "done" + assert oper.get(key) == ["ExistingPlugin", "DemoPlugin"] + assert _stored_config(key).value == ["ExistingPlugin", "DemoPlugin"] + assert _stored_config(related_key).value == {"phase": "committed"} + + +def test_update_atomically_keeps_snapshot_when_related_write_fails() -> None: + """关联写失败时配置数据库值和内存快照都保持最近提交状态。""" + key = _unique_key() + related_key = _unique_key() + oper = _fresh_oper() + oper.set(key, ["ExistingPlugin"]) + + def mutation(session, _current): + session.add(SystemConfig(key=related_key, value=True)) + raise RuntimeError("related write failed") + + with pytest.raises(RuntimeError, match="related write failed"): + oper.update_atomically(key, mutation) + + assert oper.get(key) == ["ExistingPlugin"] + assert _stored_config(key).value == ["ExistingPlugin"] + assert _stored_config(related_key) is None + + def test_increment_serializes_concurrent_counter_updates(monkeypatch): """并发递增系统计数时不应丢失更新。""" oper = object.__new__(SystemConfigOper)