mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
Vendored
+172
-12
@@ -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(
|
||||
|
||||
+31
-2
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
|
||||
|
||||
@@ -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,
|
||||
|
||||
+132
-63
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
+646
-378
File diff suppressed because it is too large
Load Diff
@@ -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__
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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:
|
||||
"""
|
||||
获取系统设置
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"文件列表为空": "檔案清單為空",
|
||||
"requirements.txt 文件下载失败": "requirements.txt 檔案下載失敗",
|
||||
"插件在仓库中不存在或返回数据格式不正确": "插件在倉庫中不存在或返回資料格式不正確",
|
||||
"插件来源身份不存在": "插件來源身分不存在",
|
||||
"插件数据解析失败": "插件資料解析失敗",
|
||||
"没有传入需要安装的依赖项": "未傳入需要安裝的依賴項",
|
||||
"资产缺少ID信息": "資產缺少 ID 資訊",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
插件仪表盘
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
+84
-14
@@ -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",
|
||||
|
||||
+131
-131
@@ -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": [
|
||||
"后台任务登记器",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
+29
-34
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user