feat(plugin): 建立可信来源准入与安装恢复 (#6462)

This commit is contained in:
InfinityPacer
2026-08-26 07:52:00 +08:00
committed by GitHub
parent 3272f72823
commit 71db425c07
58 changed files with 11471 additions and 1493 deletions
+172 -12
View File
@@ -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
View File
@@ -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()
+401 -45
View File
@@ -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():
+38 -90
View File
@@ -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]:
"""
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
+47 -2
View File
@@ -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
View File
@@ -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)
+227
View File
@@ -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,
)
+199
View File
@@ -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")
+289 -1
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+430
View File
@@ -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__
+45 -12
View File
@@ -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()
+189
View File
@@ -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()
+824
View File
@@ -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
+325
View File
@@ -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()
+51
View File
@@ -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()
+506
View File
@@ -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()
+4
View File
@@ -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",
+35
View File
@@ -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"),
)
+36 -1
View File
@@ -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:
"""
获取系统设置
+1
View File
@@ -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",
+1
View File
@@ -353,6 +353,7 @@
"文件列表为空": "檔案清單為空",
"requirements.txt 文件下载失败": "requirements.txt 檔案下載失敗",
"插件在仓库中不存在或返回数据格式不正确": "插件在倉庫中不存在或返回資料格式不正確",
"插件来源身份不存在": "插件來源身分不存在",
"插件数据解析失败": "插件資料解析失敗",
"没有传入需要安装的依赖项": "未傳入需要安裝的依賴項",
"资产缺少ID信息": "資產缺少 ID 資訊",
+1 -1
View File
@@ -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,
+41 -13
View File
@@ -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}")
+27 -1
View File
@@ -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
+13 -13
View File
@@ -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]:
+5
View File
@@ -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'),
+112
View File
@@ -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):
"""
插件仪表盘
+19 -2
View File
@@ -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,
+299 -7
View File
@@ -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()
)
+7 -5
View File
@@ -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:
+76
View File
@@ -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")
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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": [
"后台任务登记器",
+71 -42
View File
@@ -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:
+11 -1
View File
@@ -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"])
+290
View File
@@ -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
View File
@@ -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()
+388
View File
@@ -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
+375
View File
@@ -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
+304
View File
@@ -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
+728
View File
@@ -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,
)
+140
View File
@@ -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)
)
)
+289
View File
@@ -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
+168
View File
@@ -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)
+28 -3
View File
@@ -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)
+111
View File
@@ -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
+48
View File
@@ -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(
+144 -1
View File
@@ -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)
+6 -1
View File
@@ -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)
+299
View File
@@ -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"]
+207 -3
View File
@@ -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
+36
View File
@@ -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)