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: