mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
Vendored
+172
-12
@@ -16,7 +16,7 @@ import traceback
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence
|
||||
from typing import Any, Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
|
||||
|
||||
import aiofiles
|
||||
@@ -84,6 +84,14 @@ VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
|
||||
}
|
||||
|
||||
InstalledPluginsProvider = Callable[[], List[str]]
|
||||
PluginInstallGateway = Callable[
|
||||
[str, str, Optional[str], Optional[str], bool],
|
||||
Tuple[bool, str],
|
||||
]
|
||||
AsyncPluginInstallGateway = Callable[
|
||||
[str, str, Optional[str], Optional[str], bool],
|
||||
Awaitable[Tuple[bool, str]],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -101,6 +109,34 @@ def _empty_installed_plugins() -> List[str]:
|
||||
|
||||
_installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins
|
||||
|
||||
|
||||
def _unconfigured_plugin_install_gateway(
|
||||
_pid: str,
|
||||
_repo_url: str,
|
||||
_package_version: Optional[str],
|
||||
_release_version: Optional[str],
|
||||
_force_install: bool,
|
||||
) -> Tuple[bool, str]:
|
||||
"""在组合根尚未装配来源门禁时拒绝插件包写入。"""
|
||||
return False, "插件安装服务尚未完成初始化"
|
||||
|
||||
|
||||
async def _unconfigured_async_plugin_install_gateway(
|
||||
_pid: str,
|
||||
_repo_url: str,
|
||||
_package_version: Optional[str],
|
||||
_release_version: Optional[str],
|
||||
_force_install: bool,
|
||||
) -> Tuple[bool, str]:
|
||||
"""在组合根尚未装配来源门禁时拒绝异步插件包写入。"""
|
||||
return False, "插件安装服务尚未完成初始化"
|
||||
|
||||
|
||||
_plugin_install_gateway: PluginInstallGateway = _unconfigured_plugin_install_gateway
|
||||
_async_plugin_install_gateway: AsyncPluginInstallGateway = (
|
||||
_unconfigured_async_plugin_install_gateway
|
||||
)
|
||||
|
||||
def configure_installed_plugins_provider(
|
||||
provider: InstalledPluginsProvider,
|
||||
) -> None:
|
||||
@@ -109,6 +145,24 @@ def configure_installed_plugins_provider(
|
||||
_installed_plugins_provider = provider
|
||||
|
||||
|
||||
def configure_plugin_install_gateway(
|
||||
*,
|
||||
install: PluginInstallGateway,
|
||||
async_install: AsyncPluginInstallGateway,
|
||||
) -> None:
|
||||
"""由启动组合根装配公开兼容安装入口的来源门禁。"""
|
||||
global _plugin_install_gateway, _async_plugin_install_gateway
|
||||
_plugin_install_gateway = install
|
||||
_async_plugin_install_gateway = async_install
|
||||
|
||||
|
||||
def reset_plugin_install_gateway() -> None:
|
||||
"""恢复未装配状态,供隔离测试清理进程级安装入口。"""
|
||||
global _plugin_install_gateway, _async_plugin_install_gateway
|
||||
_plugin_install_gateway = _unconfigured_plugin_install_gateway
|
||||
_async_plugin_install_gateway = _unconfigured_async_plugin_install_gateway
|
||||
|
||||
|
||||
def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""规范化插件仓库地址,便于跨来源合并去重。"""
|
||||
repo_url = (repo_url or "").strip().rstrip("/")
|
||||
@@ -518,6 +572,11 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
candidate["repo_order"] = repo_order
|
||||
candidate["repo_path"] = repo_path
|
||||
candidate["path"] = plugin_dir
|
||||
candidate["repo_url"] = self.make_local_repo_url(
|
||||
pid,
|
||||
repo_path,
|
||||
package_version or None,
|
||||
)
|
||||
self.annotate_plugin_system_version(candidate)
|
||||
candidate_version = str(candidate.get("version") or "0")
|
||||
|
||||
@@ -583,6 +642,11 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
candidate["repo_order"] = repo_order
|
||||
candidate["repo_path"] = local_repo_path
|
||||
candidate["path"] = plugin_dir
|
||||
candidate["repo_url"] = self.make_local_repo_url(
|
||||
candidate_pid,
|
||||
local_repo_path,
|
||||
current_package_version or None,
|
||||
)
|
||||
if not is_compatible:
|
||||
candidate["compatible"] = False
|
||||
candidate["skip_reason"] = (
|
||||
@@ -798,11 +862,34 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
releases.extend(cls.__normalize_plugin_release_response(payload))
|
||||
return len(payload) >= 100
|
||||
|
||||
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
|
||||
def get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""读取插件索引;404 返回 None,读取失败由调用方记录。"""
|
||||
request = self._build_plugin_index_request(repo_url, package_version)
|
||||
if request is None:
|
||||
raise ValueError("插件仓库地址无效")
|
||||
package_url, headers = request
|
||||
res = self.__request_with_fallback(package_url, headers=headers)
|
||||
if res is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}")
|
||||
payload = self.__parse_plugin_index_response(res.text)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
def get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
获取Github所有最新插件列表
|
||||
获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
@@ -955,16 +1042,32 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
release_version: Optional[str] = None, force_install: bool = False) \
|
||||
-> Tuple[bool, str]:
|
||||
"""
|
||||
安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。
|
||||
通过宿主统一 Gateway 安装插件,保留第三方插件使用的同步兼容 API。
|
||||
:param pid: 插件 ID
|
||||
:param repo_url: 插件仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||
:param force_install: 是否替换已存在的插件载荷
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
return _plugin_install_gateway(
|
||||
pid,
|
||||
repo_url,
|
||||
package_version,
|
||||
release_version,
|
||||
force_install,
|
||||
)
|
||||
|
||||
def __install_package(self, pid: str, repo_url: str, package_version: Optional[str] = None,
|
||||
release_version: Optional[str] = None, force_install: bool = False) \
|
||||
-> Tuple[bool, str]:
|
||||
"""执行已通过来源准入的同步包安装,不负责身份或运行态提交。"""
|
||||
if self.is_local_repo_url(repo_url):
|
||||
return self.install_local(pid=pid, repo_url=repo_url, force_install=force_install)
|
||||
return self.__install_local_package(
|
||||
pid=pid,
|
||||
repo_url=repo_url,
|
||||
force_install=force_install,
|
||||
)
|
||||
|
||||
if SystemUtils.is_frozen():
|
||||
return False, "可执行文件模式下,只能安装本地插件"
|
||||
@@ -1045,8 +1148,24 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
|
||||
|
||||
def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]:
|
||||
"""通过宿主统一 Gateway 安装本地插件。"""
|
||||
target_repo = repo_url or self.make_local_repo_url(pid)
|
||||
return _plugin_install_gateway(
|
||||
pid,
|
||||
target_repo,
|
||||
self.parse_local_repo_package_version(target_repo),
|
||||
None,
|
||||
force_install,
|
||||
)
|
||||
|
||||
def __install_local_package(
|
||||
self,
|
||||
pid: str,
|
||||
repo_url: str = "",
|
||||
force_install: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
从本地插件仓库目录安装插件
|
||||
执行已通过来源准入的本地插件包安装。
|
||||
"""
|
||||
local_pid = self.parse_local_repo_url(repo_url) if repo_url else pid
|
||||
if not local_pid or local_pid.lower() != pid.lower():
|
||||
@@ -2158,7 +2277,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||
return False, dep_msg
|
||||
|
||||
self.refresh_persistent_plugin_backup(pid)
|
||||
if backup_dir:
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
return True, ""
|
||||
@@ -2433,11 +2551,37 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
|
||||
return None
|
||||
|
||||
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
|
||||
async def async_get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""异步读取插件索引;404 返回 None,读取失败由调用方记录。"""
|
||||
request = self._build_plugin_index_request(repo_url, package_version)
|
||||
if request is None:
|
||||
raise ValueError("插件仓库地址无效")
|
||||
package_url, headers = request
|
||||
res = await self.__async_request_with_fallback(
|
||||
package_url,
|
||||
headers=headers,
|
||||
)
|
||||
if res is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}")
|
||||
payload = self.__parse_plugin_index_response(res.text)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
async def async_get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
异步获取Github所有最新插件列表
|
||||
异步获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
@@ -3042,17 +3186,34 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
release_version: Optional[str] = None,
|
||||
force_install: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
异步安装插件,包括版本检查、内容准备、生效清单依赖安装和失败恢复。
|
||||
通过宿主统一 Gateway 安装插件,保留第三方插件使用的异步兼容 API。
|
||||
:param pid: 插件 ID
|
||||
:param repo_url: 插件仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
|
||||
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
|
||||
:param force_install: 是否强制安装插件,默认不启用,启用时不进行备份和恢复操作
|
||||
:param force_install: 是否替换已存在的插件载荷
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
return await _async_plugin_install_gateway(
|
||||
pid,
|
||||
repo_url,
|
||||
package_version,
|
||||
release_version,
|
||||
force_install,
|
||||
)
|
||||
|
||||
async def __async_install_package(
|
||||
self,
|
||||
pid: str,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
release_version: Optional[str] = None,
|
||||
force_install: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""执行已通过来源准入的异步包安装,不负责身份或运行态提交。"""
|
||||
if self.is_local_repo_url(repo_url):
|
||||
return await _await_thread_operation(
|
||||
self.install_local,
|
||||
self.__install_local_package,
|
||||
pid,
|
||||
repo_url,
|
||||
force_install,
|
||||
@@ -3187,7 +3348,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装")
|
||||
return False, dep_msg
|
||||
|
||||
await _await_thread_operation(self.refresh_persistent_plugin_backup, pid)
|
||||
return True, ""
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(
|
||||
|
||||
+31
-2
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from app.adapters.external.market import PluginHelper as _PluginHelper
|
||||
from app.runtime.cache import async_fresh, fresh
|
||||
@@ -21,7 +21,7 @@ class PluginMarketClient:
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[dict[str, dict]]:
|
||||
) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""同步读取指定仓库和代际的插件索引。"""
|
||||
with fresh(force):
|
||||
return self._helper.get_plugins(repo_url, package_version)
|
||||
@@ -36,6 +36,35 @@ class PluginMarketClient:
|
||||
async with async_fresh(force):
|
||||
return await self._helper.async_get_plugins(repo_url, package_version)
|
||||
|
||||
def get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[dict[str, dict]]:
|
||||
"""读取插件索引的三态结果,供库存读取保留失败事实。"""
|
||||
with fresh(force):
|
||||
return cast(
|
||||
Optional[dict[str, dict[str, Any]]],
|
||||
self._helper.get_plugin_index_result(repo_url, package_version),
|
||||
)
|
||||
|
||||
async def async_get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""异步读取插件索引的三态结果,供库存读取保留失败事实。"""
|
||||
async with async_fresh(force):
|
||||
return cast(
|
||||
Optional[dict[str, dict[str, Any]]],
|
||||
await self._helper.async_get_plugin_index_result(
|
||||
repo_url,
|
||||
package_version,
|
||||
),
|
||||
)
|
||||
|
||||
def get_local_candidates(self) -> dict[str, dict]:
|
||||
"""返回全部本地插件仓库候选。"""
|
||||
return self._helper.get_local_plugin_candidates()
|
||||
|
||||
@@ -2,32 +2,47 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from app.adapters.external.market import PluginHelper as _PluginHelper
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.runtime.execution import (
|
||||
run_in_threadpool_to_completion as _await_thread_operation,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
|
||||
# 保留旧模块级入口,插件本地同步测试和旧扩展仍可能覆盖这些设置。
|
||||
settings = RuntimeSettingsCompat()
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginPackageCheckpoint:
|
||||
"""记录一次插件包变更前可用于补偿恢复的文件快照。"""
|
||||
"""记录运行目录快照及待提升的容器恢复备份。"""
|
||||
|
||||
plugin_id: str
|
||||
plugin_dir: Path
|
||||
persistent_backup_dir: Path
|
||||
backup_staging_dir: Path | None
|
||||
backup_previous_dir: Path | None
|
||||
transaction_dir: Path
|
||||
existed: bool
|
||||
plugin_existed: bool
|
||||
persistent_backup_existed: bool
|
||||
|
||||
@property
|
||||
def existed(self) -> bool:
|
||||
"""保留旧调用方读取运行目录存在状态的兼容属性。"""
|
||||
return self.plugin_existed
|
||||
|
||||
@property
|
||||
def rollback_marker(self) -> Path:
|
||||
"""返回文件补偿完成标记,供 PREPARED 重放保持幂等。"""
|
||||
return self.transaction_dir / ".rollback-complete"
|
||||
|
||||
|
||||
class PluginPackageManager:
|
||||
@@ -40,7 +55,7 @@ class PluginPackageManager:
|
||||
self._helper = helper or _PluginHelper()
|
||||
|
||||
@staticmethod
|
||||
def _plugin_dir(plugin_id: str) -> Path:
|
||||
def __plugin_dir(plugin_id: str) -> Path:
|
||||
"""解析插件运行目录并拒绝越出宿主插件根目录的标识。"""
|
||||
plugins_root = (
|
||||
Path(settings.ROOT_PATH) / "app" / "plugins"
|
||||
@@ -50,18 +65,44 @@ class PluginPackageManager:
|
||||
raise ValueError(f"非法插件ID:{plugin_id}")
|
||||
return plugin_dir
|
||||
|
||||
def checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint:
|
||||
"""在包变更前创建独立快照,供后续提交或补偿恢复。"""
|
||||
plugin_dir = self._plugin_dir(plugin_id)
|
||||
transaction_dir = (
|
||||
Path(settings.TEMP_PATH)
|
||||
/ "plugin_transactions"
|
||||
/ f"{plugin_id.lower()}-{uuid.uuid4().hex}"
|
||||
def checkpoint(
|
||||
self,
|
||||
plugin_id: str,
|
||||
transaction_id: Optional[str] = None,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""在包变更前保存运行目录;持久事务使用配置目录承载恢复材料。"""
|
||||
plugin_dir = self.__plugin_dir(plugin_id)
|
||||
durable = transaction_id is not None
|
||||
persistent_backup_dir = (
|
||||
Path(settings.CONFIG_PATH) / "plugins_backup" / plugin_id.lower()
|
||||
).resolve()
|
||||
backup_staging_dir = (
|
||||
persistent_backup_dir.parent
|
||||
/ f".{plugin_id.lower()}.staging-{transaction_id}"
|
||||
if durable and SystemUtils.is_docker()
|
||||
else None
|
||||
)
|
||||
existed = plugin_dir.exists()
|
||||
backup_previous_dir = (
|
||||
persistent_backup_dir.parent
|
||||
/ f".{plugin_id.lower()}.previous-{transaction_id}"
|
||||
if durable and SystemUtils.is_docker()
|
||||
else None
|
||||
)
|
||||
transaction_root = (
|
||||
Path(settings.CONFIG_PATH)
|
||||
if durable
|
||||
else Path(settings.TEMP_PATH)
|
||||
)
|
||||
transaction_dir = (
|
||||
transaction_root
|
||||
/ "plugin_transactions"
|
||||
/ (transaction_id or f"{plugin_id.lower()}-{uuid.uuid4().hex}")
|
||||
)
|
||||
plugin_existed = plugin_dir.exists()
|
||||
persistent_backup_existed = persistent_backup_dir.exists()
|
||||
try:
|
||||
transaction_dir.mkdir(parents=True, exist_ok=False)
|
||||
if existed:
|
||||
if plugin_existed:
|
||||
shutil.copytree(plugin_dir, transaction_dir / "package")
|
||||
except Exception:
|
||||
shutil.rmtree(transaction_dir, ignore_errors=True)
|
||||
@@ -69,18 +110,77 @@ class PluginPackageManager:
|
||||
return PluginPackageCheckpoint(
|
||||
plugin_id=plugin_id,
|
||||
plugin_dir=plugin_dir,
|
||||
persistent_backup_dir=persistent_backup_dir,
|
||||
backup_staging_dir=backup_staging_dir,
|
||||
backup_previous_dir=backup_previous_dir,
|
||||
transaction_dir=transaction_dir,
|
||||
existed=existed,
|
||||
plugin_existed=plugin_existed,
|
||||
persistent_backup_existed=persistent_backup_existed,
|
||||
)
|
||||
|
||||
async def async_checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint:
|
||||
def restore_checkpoint(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
transaction_id: str,
|
||||
plugin_existed: bool,
|
||||
persistent_backup_existed: bool,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""按受控根目录和事务 ID 重建崩溃回放所需的文件引用。"""
|
||||
plugin_dir = self.__plugin_dir(plugin_id)
|
||||
persistent_backup_dir = (
|
||||
Path(settings.CONFIG_PATH) / "plugins_backup" / plugin_id.lower()
|
||||
).resolve()
|
||||
durable_backup = SystemUtils.is_docker()
|
||||
return PluginPackageCheckpoint(
|
||||
plugin_id=plugin_id,
|
||||
plugin_dir=plugin_dir,
|
||||
persistent_backup_dir=persistent_backup_dir,
|
||||
backup_staging_dir=(
|
||||
persistent_backup_dir.parent
|
||||
/ f".{plugin_id.lower()}.staging-{transaction_id}"
|
||||
if durable_backup
|
||||
else None
|
||||
),
|
||||
backup_previous_dir=(
|
||||
persistent_backup_dir.parent
|
||||
/ f".{plugin_id.lower()}.previous-{transaction_id}"
|
||||
if durable_backup
|
||||
else None
|
||||
),
|
||||
transaction_dir=(
|
||||
Path(settings.CONFIG_PATH)
|
||||
/ "plugin_transactions"
|
||||
/ transaction_id
|
||||
),
|
||||
plugin_existed=plugin_existed,
|
||||
persistent_backup_existed=persistent_backup_existed,
|
||||
)
|
||||
|
||||
async def async_checkpoint(
|
||||
self,
|
||||
plugin_id: str,
|
||||
transaction_id: Optional[str] = None,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""在线程池中创建插件包文件快照。"""
|
||||
return await _await_thread_operation(self.checkpoint, plugin_id)
|
||||
return cast(
|
||||
PluginPackageCheckpoint,
|
||||
await _await_thread_operation(
|
||||
self.checkpoint,
|
||||
plugin_id,
|
||||
transaction_id,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def commit(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""确认包变更成功并清理临时快照。"""
|
||||
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
|
||||
"""清理已完成事务的运行目录快照和残余替换材料。"""
|
||||
if checkpoint.backup_staging_dir and checkpoint.backup_staging_dir.exists():
|
||||
raise RuntimeError("持久备份尚未提升,不能清理插件安装事务")
|
||||
if checkpoint.backup_previous_dir and checkpoint.backup_previous_dir.exists():
|
||||
raise RuntimeError("旧持久备份尚未清理,不能结束插件安装事务")
|
||||
if checkpoint.transaction_dir.exists():
|
||||
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
|
||||
|
||||
async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在线程池中清理已提交的插件包快照。"""
|
||||
@@ -88,17 +188,30 @@ class PluginPackageManager:
|
||||
|
||||
@staticmethod
|
||||
def rollback(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""删除当前包并把变更前文件快照恢复到运行目录。"""
|
||||
snapshot_dir = checkpoint.transaction_dir / "package"
|
||||
if checkpoint.existed:
|
||||
if not snapshot_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 的补偿快照不存在:{snapshot_dir}"
|
||||
)
|
||||
if checkpoint.plugin_dir.exists():
|
||||
shutil.rmtree(checkpoint.plugin_dir)
|
||||
if checkpoint.existed:
|
||||
shutil.copytree(snapshot_dir, checkpoint.plugin_dir)
|
||||
"""兼容旧调用方,恢复运行目录和持久备份后清理恢复材料。"""
|
||||
PluginPackageManager.restore(checkpoint)
|
||||
PluginPackageManager.cleanup(checkpoint)
|
||||
|
||||
@staticmethod
|
||||
def restore(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""恢复运行目录和提交前持久备份,并保留快照直到 journal 删除。"""
|
||||
if checkpoint.rollback_marker.is_file():
|
||||
return
|
||||
PluginPackageManager.__restore_tree(
|
||||
target=checkpoint.plugin_dir,
|
||||
snapshot=checkpoint.transaction_dir / "package",
|
||||
existed=checkpoint.plugin_existed,
|
||||
label=f"插件 {checkpoint.plugin_id} 运行目录",
|
||||
)
|
||||
PluginPackageManager.__rollback_persistent_backup(checkpoint)
|
||||
if checkpoint.backup_staging_dir and checkpoint.backup_staging_dir.exists():
|
||||
shutil.rmtree(checkpoint.backup_staging_dir, ignore_errors=False)
|
||||
checkpoint.transaction_dir.mkdir(parents=True, exist_ok=True)
|
||||
checkpoint.rollback_marker.touch(exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def cleanup(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在 journal 已删除后清理恢复材料;重复调用保持幂等。"""
|
||||
if checkpoint.transaction_dir.exists():
|
||||
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
|
||||
|
||||
@@ -106,6 +219,243 @@ class PluginPackageManager:
|
||||
"""在线程池中恢复插件包文件快照。"""
|
||||
await _await_thread_operation(self.rollback, checkpoint)
|
||||
|
||||
async def async_restore(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在线程池恢复插件状态,并保留 journal 仍需引用的材料。"""
|
||||
await _await_thread_operation(self.restore, checkpoint)
|
||||
|
||||
async def async_cleanup(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在线程池清理已失去 journal 所有权的恢复材料。"""
|
||||
await _await_thread_operation(self.cleanup, checkpoint)
|
||||
|
||||
@staticmethod
|
||||
def __rollback_persistent_backup(
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> None:
|
||||
"""把已激活但尚未提交的持久备份恢复到事务前状态。"""
|
||||
previous = checkpoint.backup_previous_dir
|
||||
staging = checkpoint.backup_staging_dir
|
||||
if previous is None or staging is None:
|
||||
return
|
||||
|
||||
target = checkpoint.persistent_backup_dir
|
||||
if previous.exists():
|
||||
discarded = target.parent / f".{target.name}.discard-{uuid.uuid4().hex}"
|
||||
try:
|
||||
if target.exists():
|
||||
target.replace(discarded)
|
||||
previous.replace(target)
|
||||
if discarded.exists():
|
||||
shutil.rmtree(discarded, ignore_errors=False)
|
||||
except Exception:
|
||||
if not target.exists() and discarded.exists():
|
||||
discarded.replace(target)
|
||||
raise
|
||||
finally:
|
||||
if target.exists() and discarded.exists():
|
||||
shutil.rmtree(discarded, ignore_errors=True)
|
||||
return
|
||||
|
||||
if staging.exists():
|
||||
return
|
||||
if checkpoint.persistent_backup_existed:
|
||||
if target.exists():
|
||||
return
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 的旧持久备份恢复材料不存在"
|
||||
)
|
||||
if target.exists():
|
||||
shutil.rmtree(target, ignore_errors=False)
|
||||
|
||||
@staticmethod
|
||||
def __restore_tree(
|
||||
*,
|
||||
target: Path,
|
||||
snapshot: Path,
|
||||
existed: bool,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""用同级 staging 替换目录,失败时保留替换前的当前目录。"""
|
||||
if existed and not snapshot.is_dir():
|
||||
raise FileNotFoundError(f"{label}补偿快照不存在:{snapshot}")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.restore-{uuid.uuid4().hex}"
|
||||
previous = target.parent / f".{target.name}.previous-{uuid.uuid4().hex}"
|
||||
try:
|
||||
if existed:
|
||||
shutil.copytree(snapshot, staging)
|
||||
if target.exists():
|
||||
target.replace(previous)
|
||||
if existed:
|
||||
staging.replace(target)
|
||||
if previous.exists():
|
||||
shutil.rmtree(previous)
|
||||
except Exception:
|
||||
if not target.exists() and previous.exists():
|
||||
previous.replace(target)
|
||||
raise
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if target.exists() and previous.exists():
|
||||
shutil.rmtree(previous, ignore_errors=True)
|
||||
|
||||
@classmethod
|
||||
def stage_persistent_backup(cls, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""把新载荷复制到持久配置目录的独立 staging,不覆盖现有备份。"""
|
||||
staging = checkpoint.backup_staging_dir
|
||||
if staging is None:
|
||||
return
|
||||
if not checkpoint.plugin_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 运行目录不存在"
|
||||
)
|
||||
staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=False)
|
||||
shutil.copytree(
|
||||
checkpoint.plugin_dir,
|
||||
staging,
|
||||
ignore=shutil.ignore_patterns(*cls._COPY_IGNORE),
|
||||
)
|
||||
|
||||
async def async_stage_persistent_backup(
|
||||
self,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> None:
|
||||
"""在线程池准备新载荷的容器恢复备份。"""
|
||||
await _await_thread_operation(self.stage_persistent_backup, checkpoint)
|
||||
|
||||
@staticmethod
|
||||
def activate_persistent_backup(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在数据库提交前激活新备份,并保留上一份备份供失败补偿。"""
|
||||
staging = checkpoint.backup_staging_dir
|
||||
previous = checkpoint.backup_previous_dir
|
||||
if staging is None or previous is None:
|
||||
return
|
||||
|
||||
target = checkpoint.persistent_backup_dir
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if staging.exists():
|
||||
if target.exists() and not previous.exists():
|
||||
target.replace(previous)
|
||||
if not target.exists():
|
||||
staging.replace(target)
|
||||
elif not target.exists():
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 的持久备份 staging 不存在"
|
||||
)
|
||||
|
||||
async def async_activate_persistent_backup(
|
||||
self,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> None:
|
||||
"""在线程池激活新持久备份,同时保留失败补偿材料。"""
|
||||
await _await_thread_operation(self.activate_persistent_backup, checkpoint)
|
||||
|
||||
@staticmethod
|
||||
def finalize_persistent_backup(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""数据库提交后清理上一份持久备份;重复调用保持幂等。"""
|
||||
staging = checkpoint.backup_staging_dir
|
||||
previous = checkpoint.backup_previous_dir
|
||||
if staging is None or previous is None:
|
||||
return
|
||||
if staging.exists():
|
||||
raise RuntimeError("新持久备份尚未激活")
|
||||
if not checkpoint.persistent_backup_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 的已提交持久备份不存在"
|
||||
)
|
||||
if previous.exists():
|
||||
shutil.rmtree(previous, ignore_errors=False)
|
||||
|
||||
async def async_finalize_persistent_backup(
|
||||
self,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> None:
|
||||
"""在线程池清理数据库提交后的旧持久备份。"""
|
||||
await _await_thread_operation(self.finalize_persistent_backup, checkpoint)
|
||||
|
||||
def payload_receipt(self, plugin_id: str) -> str:
|
||||
"""按稳定相对路径和文件内容计算已安装载荷收据。"""
|
||||
plugin_dir = self.__plugin_dir(plugin_id)
|
||||
if not plugin_dir.is_dir():
|
||||
raise FileNotFoundError(f"插件 {plugin_id} 运行目录不存在")
|
||||
return self.__tree_receipt(plugin_dir)
|
||||
|
||||
@classmethod
|
||||
def persistent_backup_receipt(
|
||||
cls,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> str:
|
||||
"""计算已提升持久备份的内容收据,供崩溃回放确认终态。"""
|
||||
if not checkpoint.persistent_backup_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 持久备份不存在"
|
||||
)
|
||||
return cls.__tree_receipt(checkpoint.persistent_backup_dir)
|
||||
|
||||
@classmethod
|
||||
def __tree_receipt(cls, root: Path) -> str:
|
||||
"""对插件目录使用稳定路径和文件内容生成审计收据。"""
|
||||
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(
|
||||
root.rglob("*"),
|
||||
key=lambda item: item.relative_to(root).as_posix(),
|
||||
):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if cls.__ignored_receipt_path(path, root):
|
||||
continue
|
||||
encoded_path = relative.encode("utf-8")
|
||||
digest.update(len(encoded_path).to_bytes(4, "big"))
|
||||
digest.update(encoded_path)
|
||||
if path.is_symlink():
|
||||
digest.update(b"L")
|
||||
target = path.readlink().as_posix().encode("utf-8")
|
||||
digest.update(len(target).to_bytes(4, "big"))
|
||||
digest.update(target)
|
||||
elif path.is_dir():
|
||||
digest.update(b"D")
|
||||
elif path.is_file():
|
||||
digest.update(b"F")
|
||||
with path.open("rb") as file_handle:
|
||||
for chunk in iter(lambda: file_handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return f"sha256:{digest.hexdigest()}"
|
||||
|
||||
async def async_payload_receipt(self, plugin_id: str) -> str:
|
||||
"""在线程池计算插件载荷收据。"""
|
||||
return cast(
|
||||
str,
|
||||
await _await_thread_operation(self.payload_receipt, plugin_id),
|
||||
)
|
||||
|
||||
async def async_committed_payload_receipt(
|
||||
self,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> str:
|
||||
"""读取数据库已提交载荷在当前部署模式下的恢复事实。"""
|
||||
if checkpoint.backup_staging_dir is not None:
|
||||
return cast(
|
||||
str,
|
||||
await _await_thread_operation(
|
||||
self.persistent_backup_receipt,
|
||||
checkpoint,
|
||||
),
|
||||
)
|
||||
return await self.async_payload_receipt(checkpoint.plugin_id)
|
||||
|
||||
@classmethod
|
||||
def __ignored_receipt_path(cls, path: Path, root: Path) -> bool:
|
||||
"""排除不会进入运行载荷和持久备份的派生文件。"""
|
||||
relative_parts = path.relative_to(root).parts
|
||||
return any(
|
||||
part in {"__pycache__", "node_modules", ".DS_Store"}
|
||||
or part.endswith(".pyc")
|
||||
for part in relative_parts
|
||||
)
|
||||
|
||||
def install(
|
||||
self,
|
||||
plugin_id: str,
|
||||
@@ -115,12 +465,15 @@ class PluginPackageManager:
|
||||
force_install: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
"""同步安装插件包,下载过程继续复用既有市场兼容策略。"""
|
||||
return self._helper.install(
|
||||
pid=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force_install=force_install,
|
||||
return cast(
|
||||
tuple[bool, str],
|
||||
cast(Any, self._helper)._PluginHelper__install_package(
|
||||
pid=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force_install=force_install,
|
||||
),
|
||||
)
|
||||
|
||||
async def async_install(
|
||||
@@ -132,18 +485,21 @@ class PluginPackageManager:
|
||||
force_install: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
"""异步安装插件包,下载过程继续复用既有市场兼容策略。"""
|
||||
return await self._helper.async_install(
|
||||
pid=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force_install=force_install,
|
||||
return cast(
|
||||
tuple[bool, str],
|
||||
await cast(Any, self._helper)._PluginHelper__async_install_package(
|
||||
pid=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force_install=force_install,
|
||||
),
|
||||
)
|
||||
|
||||
def sync_local(self, plugin_id: str, source_dir: Path) -> bool:
|
||||
"""用本地仓库内容原子替换运行副本,失败时恢复原目录。"""
|
||||
source_dir = source_dir.resolve()
|
||||
plugin_dir = self._plugin_dir(plugin_id)
|
||||
plugin_dir = self.__plugin_dir(plugin_id)
|
||||
if source_dir == plugin_dir:
|
||||
return True
|
||||
checkpoint = self.checkpoint(plugin_id)
|
||||
@@ -181,8 +537,8 @@ class PluginPackageManager:
|
||||
icon: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""复制并改写插件分身文件,任一步失败都删除不完整目标。"""
|
||||
original_dir = self._plugin_dir(plugin_id)
|
||||
clone_dir = self._plugin_dir(clone_id)
|
||||
original_dir = self.__plugin_dir(plugin_id)
|
||||
clone_dir = self.__plugin_dir(clone_id)
|
||||
if not original_dir.is_dir():
|
||||
return False, f"原插件目录 {original_dir} 不存在"
|
||||
if clone_dir.exists():
|
||||
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from contextvars import copy_context
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.application.plugin.install import PluginInstallCommand
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.application.plugin.gateway import get_plugin_install_service
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
|
||||
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
|
||||
DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
|
||||
MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
|
||||
@@ -314,97 +311,48 @@ def summarize_candidates(matches: list[dict[str, Any]], limit: int = DEFAULT_PLU
|
||||
|
||||
|
||||
async def install_plugin_runtime(
|
||||
plugin_id: str, repo_url: Optional[str], force: bool = False
|
||||
plugin_id: str,
|
||||
repo_url: Optional[str],
|
||||
force: bool = False,
|
||||
*,
|
||||
explicit_source: bool = False,
|
||||
) -> tuple[bool, str, bool]:
|
||||
"""
|
||||
按现有插件接口的行为安装插件,并刷新运行态注册信息。
|
||||
"""
|
||||
plugin_manager = get_plugin_manager()
|
||||
plugin_helper = PluginHelper()
|
||||
package_manager = PluginPackageManager(plugin_helper)
|
||||
|
||||
from app.agent.tools.base import run_agent_blocking
|
||||
|
||||
async def save_installed_plugins(plugin_ids: list[str]) -> object:
|
||||
"""保存智能体安装用例确认后的插件列表。"""
|
||||
return await get_configured_system_config().async_set(
|
||||
SystemConfigKey.UserInstalledPlugins,
|
||||
plugin_ids,
|
||||
)
|
||||
|
||||
async def install_package(
|
||||
target_id: str,
|
||||
target_repo: str,
|
||||
_release_version: Optional[str],
|
||||
force_install: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""调用插件包适配器执行异步安装。"""
|
||||
return await package_manager.async_install(
|
||||
plugin_id=target_id,
|
||||
repo_url=target_repo,
|
||||
force_install=force_install,
|
||||
)
|
||||
|
||||
async def skip_compatibility_check(
|
||||
_target_id: str,
|
||||
_target_repo: str,
|
||||
) -> None:
|
||||
"""保持 Agent 旧安装入口不额外执行系统版本预检查。"""
|
||||
return None
|
||||
|
||||
async def reload_runtime(target_id: str) -> object:
|
||||
"""通过 Agent 阻塞任务适配器重载源插件及其虚拟实例。"""
|
||||
mutation_context = copy_context()
|
||||
return await run_agent_blocking(
|
||||
"plugin",
|
||||
mutation_context.run,
|
||||
plugin_manager.reload_plugin_tree,
|
||||
target_id,
|
||||
)
|
||||
|
||||
async def refresh_registrations(target_id: str) -> object:
|
||||
"""通过 Agent 阻塞任务适配器刷新源插件及其虚拟实例注册。"""
|
||||
result = None
|
||||
reload_targets = list(
|
||||
plugin_manager.get_plugin_reload_targets(target_id)
|
||||
) or [target_id]
|
||||
for reload_target in reload_targets:
|
||||
result = await run_agent_blocking(
|
||||
"plugin",
|
||||
refresh_plugin_registrations,
|
||||
reload_target,
|
||||
)
|
||||
return result
|
||||
|
||||
result = await PluginInstallCommand(
|
||||
installed_plugins_reader=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
installed_plugins_writer=save_installed_plugins,
|
||||
plugin_ids_provider=plugin_manager.get_plugin_ids,
|
||||
compatibility_checker=skip_compatibility_check,
|
||||
package_installer=install_package,
|
||||
package_checkpointer=package_manager.async_checkpoint,
|
||||
package_committer=package_manager.async_commit,
|
||||
package_rollback=package_manager.async_rollback,
|
||||
install_reporter=lambda target_id, target_repo: (
|
||||
MoviePilotServerHelper.async_install_plugin_reg(
|
||||
plugin_id=target_id,
|
||||
repo_url=target_repo,
|
||||
)
|
||||
),
|
||||
plugin_reloader=reload_runtime,
|
||||
registration_refresher=refresh_registrations,
|
||||
mutation=plugin_manager.mutation,
|
||||
package_write_guard=plugin_manager.suppress_plugin_monitor,
|
||||
).execute(
|
||||
result = await get_plugin_install_service().install(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
repo_url=repo_url or None,
|
||||
force=force,
|
||||
explicit_source=explicit_source,
|
||||
)
|
||||
return result.success, result.message, result.refreshed_only
|
||||
|
||||
|
||||
async def inspect_plugin_sources(
|
||||
plugin_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""返回 Agent 可展示的脱敏来源候选与当前准入状态。"""
|
||||
inspection = await get_plugin_install_service().inspect_source(
|
||||
plugin_id=plugin_id,
|
||||
force=force,
|
||||
)
|
||||
candidates = [
|
||||
candidate.public_dict()
|
||||
for candidate in inspection.online_candidates
|
||||
]
|
||||
if inspection.local_candidate is not None:
|
||||
candidates.append(inspection.local_candidate.public_dict())
|
||||
return {
|
||||
"selection_status": inspection.selection.status.value,
|
||||
"selection_reason": inspection.selection.reason,
|
||||
"inventory_complete": inspection.inventory_complete,
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
|
||||
async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import json
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.agent.tools.impl._plugin_tool_utils import (
|
||||
get_plugin_snapshot,
|
||||
inspect_plugin_sources,
|
||||
install_plugin_runtime,
|
||||
load_market_plugins,
|
||||
summarize_plugin,
|
||||
@@ -31,6 +32,26 @@ class InstallPluginInput(BaseModel):
|
||||
False,
|
||||
description="Whether to refresh plugin market caches before reading the market list.",
|
||||
)
|
||||
repo_url: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Exact repository URL explicitly selected by the administrator. "
|
||||
"Only set it after a source conflict is shown to the user."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("repo_url")
|
||||
@classmethod
|
||||
def normalize_repo_url(cls, value: Optional[str]) -> Optional[str]:
|
||||
"""显式来源必须是非空在线仓库地址。"""
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("Explicit source repository URL cannot be empty.")
|
||||
if normalized.startswith("local://"):
|
||||
raise ValueError("Explicit source selection only accepts online repositories.")
|
||||
return normalized
|
||||
|
||||
|
||||
class InstallPluginTool(MoviePilotTool):
|
||||
@@ -56,6 +77,7 @@ class InstallPluginTool(MoviePilotTool):
|
||||
plugin_id: str,
|
||||
force: bool = False,
|
||||
force_refresh_market: bool = False,
|
||||
repo_url: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
logger.info(
|
||||
@@ -82,10 +104,33 @@ class InstallPluginTool(MoviePilotTool):
|
||||
|
||||
success, message, refreshed_only = await install_plugin_runtime(
|
||||
candidate.id,
|
||||
getattr(candidate, "repo_url", None),
|
||||
repo_url,
|
||||
force=force,
|
||||
explicit_source=repo_url is not None,
|
||||
)
|
||||
if not success:
|
||||
source_options = await inspect_plugin_sources(
|
||||
candidate.id,
|
||||
force=False,
|
||||
)
|
||||
if (
|
||||
repo_url is None
|
||||
and source_options["selection_status"] in {
|
||||
"conflict",
|
||||
"incomplete",
|
||||
}
|
||||
):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"plugin": summarize_plugin(candidate),
|
||||
"message": source_options["selection_reason"],
|
||||
"requires_explicit_source": True,
|
||||
"source_candidates": source_options["candidates"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
|
||||
+132
-63
@@ -11,7 +11,6 @@ from starlette.responses import StreamingResponse
|
||||
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.adapters.web.security.access import (
|
||||
resource_token_cookie,
|
||||
verify_resource_token,
|
||||
@@ -31,12 +30,12 @@ from app.application.commands import init_commands
|
||||
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.application.plugin.folders import remove_plugin_from_folders
|
||||
from app.application.plugin.install import PluginInstallCommand
|
||||
from app.application.plugin.gateway import get_plugin_install_service
|
||||
from app.application.plugin.routes import register_plugin_api, remove_plugin_api
|
||||
from app.application.plugin.runtime import PluginRuntime, get_plugin_manager
|
||||
from app.application.plugin.transaction import get_plugin_persistence
|
||||
from app.application.scheduling import remove_plugin_job, update_plugin_job
|
||||
from app.runtime.cache import async_fresh
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.extensions.plugin.contracts import (
|
||||
PluginDashboardError,
|
||||
PluginNotFoundError,
|
||||
@@ -61,6 +60,11 @@ from app.schemas.plugin import PluginRemoteInfo as _SchemaPluginRemoteInfo
|
||||
from app.schemas.plugin import PluginRuntimeStatus as _SchemaPluginRuntimeStatus
|
||||
from app.schemas.plugin import PluginRuntimeSummary as _SchemaPluginRuntimeSummary
|
||||
from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem
|
||||
from app.schemas.plugin import PluginSourceCandidate as _SchemaPluginSourceCandidate
|
||||
from app.schemas.plugin import PluginSourceChangeRequest as _SchemaPluginSourceChangeRequest
|
||||
from app.schemas.plugin import PluginSourceIdentity as _SchemaPluginSourceIdentity
|
||||
from app.schemas.plugin import PluginSourceInstallRequest as _SchemaPluginSourceInstallRequest
|
||||
from app.schemas.plugin import PluginSourceOptions as _SchemaPluginSourceOptions
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -69,6 +73,19 @@ router = ResponseAPIRouter()
|
||||
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _plugin_source_identity_schema(identity: Any) -> _SchemaPluginSourceIdentity:
|
||||
"""把持久化身份映射为公共来源确认 DTO。"""
|
||||
return _SchemaPluginSourceIdentity(
|
||||
plugin_id=identity.plugin_id,
|
||||
trusted_source_type=identity.trusted_source_type.value,
|
||||
trusted_source_key=identity.trusted_source_key,
|
||||
binding_basis=identity.binding_basis.value,
|
||||
payload_source_type=identity.payload_source_type.value,
|
||||
payload_source_key=identity.payload_source_key,
|
||||
revision=identity.revision,
|
||||
)
|
||||
|
||||
|
||||
async def _get_market_plugin_from_repo(
|
||||
plugin_manager: PluginRuntime,
|
||||
plugin_id: str,
|
||||
@@ -554,69 +571,121 @@ async def install(
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
plugin_helper = PluginHelper()
|
||||
package_manager = PluginPackageManager(plugin_helper)
|
||||
plugin_manager = get_plugin_manager()
|
||||
|
||||
async def save_installed_plugins(plugin_ids: List[str]) -> object:
|
||||
"""保存安装用例确认后的插件列表。"""
|
||||
return await get_configured_system_config().async_set(
|
||||
SystemConfigKey.UserInstalledPlugins,
|
||||
plugin_ids,
|
||||
)
|
||||
|
||||
async def install_package(
|
||||
target_id: str,
|
||||
target_repo: str,
|
||||
target_release: Optional[str],
|
||||
force_install: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""调用插件包适配器执行异步安装。"""
|
||||
return await package_manager.async_install(
|
||||
plugin_id=target_id,
|
||||
repo_url=target_repo,
|
||||
release_version=target_release,
|
||||
force_install=force_install,
|
||||
)
|
||||
|
||||
async def reload_runtime(target_id: str) -> object:
|
||||
"""在线程池中重建源插件及其全部虚拟实例。"""
|
||||
return await run_in_threadpool(
|
||||
get_plugin_manager().reload_plugin_tree, target_id
|
||||
)
|
||||
|
||||
async def refresh_registrations(target_id: str) -> object:
|
||||
"""在线程池中刷新源插件及其虚拟实例的全部宿主注册。"""
|
||||
for reload_target in plugin_manager.get_plugin_reload_targets(target_id):
|
||||
await run_in_threadpool(register_plugin, reload_target)
|
||||
|
||||
command = PluginInstallCommand(
|
||||
installed_plugins_reader=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
installed_plugins_writer=save_installed_plugins,
|
||||
plugin_ids_provider=lambda: get_plugin_manager().get_plugin_ids(),
|
||||
compatibility_checker=plugin_helper.async_get_plugin_system_version_check_message,
|
||||
package_installer=install_package,
|
||||
package_checkpointer=package_manager.async_checkpoint,
|
||||
package_committer=package_manager.async_commit,
|
||||
package_rollback=package_manager.async_rollback,
|
||||
install_reporter=lambda target_id, target_repo: (
|
||||
MoviePilotServerHelper.async_install_plugin_reg(
|
||||
plugin_id=target_id,
|
||||
repo_url=target_repo,
|
||||
)
|
||||
),
|
||||
plugin_reloader=reload_runtime,
|
||||
registration_refresher=refresh_registrations,
|
||||
mutation=plugin_manager.mutation,
|
||||
package_write_guard=plugin_manager.suppress_plugin_monitor,
|
||||
)
|
||||
result = await command.execute(
|
||||
result = await get_plugin_install_service().install(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
repo_url=None,
|
||||
release_version=release_version,
|
||||
force=bool(force),
|
||||
explicit_source=False,
|
||||
)
|
||||
if not result.success:
|
||||
return _SchemaResponse(success=False, message=result.message)
|
||||
return _SchemaResponse(success=True)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/source/{plugin_id}",
|
||||
summary="获取插件来源身份",
|
||||
response_model=_SchemaResponse[_SchemaPluginSourceIdentity],
|
||||
)
|
||||
async def get_plugin_source_identity(
|
||||
plugin_id: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""返回显式换源确认所需的当前可信来源和 revision。"""
|
||||
identity = await get_plugin_persistence().get_identity(plugin_id)
|
||||
if identity is None:
|
||||
return _SchemaResponse(success=False, message="插件来源身份不存在")
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
data=_plugin_source_identity_schema(identity),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/source/{plugin_id}/options",
|
||||
summary="获取插件来源候选",
|
||||
response_model=_SchemaResponse[_SchemaPluginSourceOptions],
|
||||
)
|
||||
async def get_plugin_source_options(
|
||||
plugin_id: str,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
force: bool = False,
|
||||
) -> Any:
|
||||
"""返回与真实安装相同库存中的脱敏候选和当前准入状态。"""
|
||||
inspection = await get_plugin_install_service().inspect_source(
|
||||
plugin_id=plugin_id,
|
||||
force=force,
|
||||
)
|
||||
candidates = [
|
||||
_SchemaPluginSourceCandidate.model_validate(candidate.public_dict())
|
||||
for candidate in inspection.online_candidates
|
||||
]
|
||||
if inspection.local_candidate is not None:
|
||||
candidates.append(
|
||||
_SchemaPluginSourceCandidate.model_validate(
|
||||
inspection.local_candidate.public_dict()
|
||||
)
|
||||
)
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
data=_SchemaPluginSourceOptions(
|
||||
plugin_id=inspection.plugin_id,
|
||||
inventory_complete=inspection.inventory_complete,
|
||||
selection_status=inspection.selection.status.value,
|
||||
selection_reason=inspection.selection.reason,
|
||||
identity=(
|
||||
_plugin_source_identity_schema(inspection.identity)
|
||||
if inspection.identity is not None
|
||||
else None
|
||||
),
|
||||
candidates=candidates,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/source/{plugin_id}/install",
|
||||
summary="按明确来源安装插件",
|
||||
response_model=_SchemaResponse[None],
|
||||
)
|
||||
async def install_plugin_from_source(
|
||||
plugin_id: str,
|
||||
request: _SchemaPluginSourceInstallRequest,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""安装管理员明确选择的初始在线来源,不承担已绑定插件换源。"""
|
||||
result = await get_plugin_install_service().install(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=request.repo_url,
|
||||
release_version=request.release_version,
|
||||
force=request.force,
|
||||
explicit_source=True,
|
||||
)
|
||||
if not result.success:
|
||||
return _SchemaResponse(success=False, message=result.message)
|
||||
return _SchemaResponse(success=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/source/{plugin_id}",
|
||||
summary="切换插件来源",
|
||||
response_model=_SchemaResponse[None],
|
||||
)
|
||||
async def change_plugin_source(
|
||||
plugin_id: str,
|
||||
request: _SchemaPluginSourceChangeRequest,
|
||||
_: ApiPrincipal = Depends(get_current_active_superuser_async),
|
||||
) -> Any:
|
||||
"""按精确身份 revision 安装明确选择的新在线来源。"""
|
||||
result = await get_plugin_install_service().install(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=request.repo_url,
|
||||
release_version=request.release_version,
|
||||
force=True,
|
||||
explicit_source=True,
|
||||
source_change=True,
|
||||
expected_revision=request.expected_revision,
|
||||
)
|
||||
if not result.success:
|
||||
return _SchemaResponse(success=False, message=result.message)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""插件载荷来源准入与目标身份规划。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.inventory import normalize_github_plugin_source
|
||||
from app.application.plugin.source import (
|
||||
Candidate,
|
||||
CandidateInventory,
|
||||
PluginLocalCandidate,
|
||||
PluginSelectionStatus,
|
||||
parse_local_plugin_reference,
|
||||
select_plugin_candidate,
|
||||
)
|
||||
|
||||
|
||||
class PluginSourceAdmissionError(RuntimeError):
|
||||
"""插件来源冲突、库存不完整或换源授权无效。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallAdmissionRequest:
|
||||
"""一次安装调用中会影响来源选择的显式业务参数。"""
|
||||
|
||||
plugin_id: str
|
||||
generations: Sequence[str]
|
||||
requested_repo_url: str | None = None
|
||||
explicit_source: bool = False
|
||||
source_change: bool = False
|
||||
expected_revision: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallAdmission:
|
||||
"""下载前冻结的候选与身份转换决策。"""
|
||||
|
||||
candidate: Candidate
|
||||
identity_before: PluginIdentity | None
|
||||
binding_basis: PluginBindingBasis
|
||||
trusted_source_type: TrustedPluginSourceType
|
||||
trusted_source_key: str | None
|
||||
bound_at: datetime | None
|
||||
|
||||
@property
|
||||
def expected_revision(self) -> int | None:
|
||||
"""返回最终数据库提交必须匹配的身份 revision。"""
|
||||
return self.identity_before.revision if self.identity_before else None
|
||||
|
||||
def build_identity(
|
||||
self,
|
||||
*,
|
||||
payload_receipt: str,
|
||||
applied_at: datetime,
|
||||
declared_version: str | None = None,
|
||||
) -> PluginIdentity:
|
||||
"""在载荷落盘并生成收据后构造唯一数据库提交目标。"""
|
||||
current = self.identity_before
|
||||
plugin_id = current.plugin_id if current else self.candidate.plugin_id
|
||||
metadata = self.candidate.dto if isinstance(self.candidate.dto, Mapping) else {}
|
||||
system_version = metadata.get("system_version")
|
||||
supports_v3 = metadata.get("v3")
|
||||
supports_v3t = metadata.get("v3t")
|
||||
source_binding_changed = (
|
||||
self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
and (
|
||||
current is None
|
||||
or current.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
or current.trusted_source_type is not self.trusted_source_type
|
||||
or current.trusted_source_key != self.trusted_source_key
|
||||
)
|
||||
)
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=self.trusted_source_type,
|
||||
trusted_source_key=self.trusted_source_key,
|
||||
binding_basis=self.binding_basis,
|
||||
payload_source_type=self.candidate.payload_source_type,
|
||||
payload_source_key=(
|
||||
None
|
||||
if isinstance(self.candidate, PluginLocalCandidate)
|
||||
else self.candidate.source_key
|
||||
),
|
||||
declared_version=declared_version or self.candidate.plugin_version,
|
||||
package_generation=self.candidate.package_generation,
|
||||
system_version=(
|
||||
system_version if isinstance(system_version, str) else None
|
||||
),
|
||||
supports_v3=supports_v3 if isinstance(supports_v3, bool) else None,
|
||||
supports_v3t=supports_v3t if isinstance(supports_v3t, bool) else None,
|
||||
payload_receipt=payload_receipt,
|
||||
revision=(current.revision + 1) if current else 1,
|
||||
created_at=current.created_at if current else applied_at,
|
||||
updated_at=applied_at,
|
||||
bound_at=applied_at if source_binding_changed else self.bound_at,
|
||||
payload_applied_at=applied_at,
|
||||
)
|
||||
|
||||
|
||||
def admit_plugin_install(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
request: PluginInstallAdmissionRequest,
|
||||
identity: PluginIdentity | None,
|
||||
now: datetime,
|
||||
) -> PluginInstallAdmission:
|
||||
"""选择唯一允许载荷,并冻结最终身份转换的可信来源边界。"""
|
||||
if now.tzinfo is None:
|
||||
raise PluginSourceAdmissionError("插件安装准入时间必须包含时区")
|
||||
if identity is not None and now < identity.updated_at:
|
||||
raise PluginSourceAdmissionError("插件安装准入时间不能早于当前身份更新时间")
|
||||
bound_at: datetime | None
|
||||
if request.source_change:
|
||||
if not request.explicit_source or not request.requested_repo_url:
|
||||
raise PluginSourceAdmissionError("显式换源必须指定目标在线来源")
|
||||
if request.requested_repo_url.startswith("local://"):
|
||||
raise PluginSourceAdmissionError("显式换源只接受在线插件仓库")
|
||||
if identity is None or identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginSourceAdmissionError("显式换源要求插件已经绑定在线来源")
|
||||
if request.expected_revision != identity.revision:
|
||||
raise PluginSourceAdmissionError("显式换源的身份 revision 已失效")
|
||||
elif request.expected_revision is not None:
|
||||
raise PluginSourceAdmissionError("普通安装不能携带换源 revision")
|
||||
|
||||
requested_source_key = None
|
||||
local_candidates = None
|
||||
if request.requested_repo_url:
|
||||
if request.requested_repo_url.startswith("local://"):
|
||||
referenced_plugin_id = parse_local_plugin_reference(
|
||||
request.requested_repo_url
|
||||
)
|
||||
if (
|
||||
referenced_plugin_id is None
|
||||
or referenced_plugin_id.lower() != request.plugin_id.lower()
|
||||
):
|
||||
raise PluginSourceAdmissionError(
|
||||
"明确选择的本地来源与目标插件不一致"
|
||||
)
|
||||
available_local_candidates = inventory.local_candidates_for(
|
||||
request.plugin_id
|
||||
)
|
||||
exact_candidates = tuple(
|
||||
candidate
|
||||
for candidate in available_local_candidates
|
||||
if candidate.repo_url == request.requested_repo_url
|
||||
)
|
||||
local_candidates = exact_candidates or available_local_candidates
|
||||
if not local_candidates:
|
||||
raise PluginSourceAdmissionError("明确选择的本地来源没有当前插件候选")
|
||||
else:
|
||||
requested_source_key, _repo_url = normalize_github_plugin_source(
|
||||
request.requested_repo_url
|
||||
)
|
||||
|
||||
selection = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id=request.plugin_id,
|
||||
generations=request.generations,
|
||||
identity=identity,
|
||||
local_candidates=local_candidates,
|
||||
requested_source_key=requested_source_key,
|
||||
explicit_source=request.explicit_source,
|
||||
allow_source_change=request.source_change,
|
||||
)
|
||||
if selection.status is not PluginSelectionStatus.SELECTED or selection.candidate is None:
|
||||
raise PluginSourceAdmissionError(selection.reason or "插件来源准入失败")
|
||||
candidate = selection.candidate
|
||||
if not candidate.plugin_version:
|
||||
raise PluginSourceAdmissionError("插件候选缺少可持久化的版本声明")
|
||||
|
||||
if isinstance(candidate, PluginLocalCandidate):
|
||||
if identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
|
||||
return PluginInstallAdmission(
|
||||
candidate=candidate,
|
||||
identity_before=identity,
|
||||
binding_basis=identity.binding_basis,
|
||||
trusted_source_type=identity.trusted_source_type,
|
||||
trusted_source_key=identity.trusted_source_key,
|
||||
bound_at=identity.bound_at,
|
||||
)
|
||||
return PluginInstallAdmission(
|
||||
candidate=candidate,
|
||||
identity_before=identity,
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
bound_at=None,
|
||||
)
|
||||
|
||||
if request.source_change:
|
||||
if (
|
||||
identity is not None
|
||||
and identity.trusted_source_type is candidate.source_type
|
||||
and identity.trusted_source_key == candidate.source_key
|
||||
):
|
||||
raise PluginSourceAdmissionError("显式换源的目标必须不同于当前来源")
|
||||
basis = PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
bound_at = now
|
||||
elif identity is not None and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN:
|
||||
basis = identity.binding_basis
|
||||
bound_at = identity.bound_at
|
||||
elif request.explicit_source:
|
||||
basis = PluginBindingBasis.EXPLICIT_INSTALL
|
||||
bound_at = now
|
||||
elif candidate.source_type is TrustedPluginSourceType.OFFICIAL:
|
||||
basis = PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
bound_at = now
|
||||
else:
|
||||
basis = PluginBindingBasis.TOFU
|
||||
bound_at = now
|
||||
|
||||
return PluginInstallAdmission(
|
||||
candidate=candidate,
|
||||
identity_before=identity,
|
||||
binding_basis=basis,
|
||||
trusted_source_type=candidate.source_type,
|
||||
trusted_source_key=candidate.source_key,
|
||||
bound_at=bound_at,
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""统一插件安装 Gateway。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.admission import (
|
||||
PluginInstallAdmission,
|
||||
PluginInstallAdmissionRequest,
|
||||
PluginSourceAdmissionError,
|
||||
admit_plugin_install,
|
||||
)
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
from app.application.plugin.install import PluginInstallResult
|
||||
from app.application.plugin.inventory import PLUGIN_V3_GENERATIONS
|
||||
from app.application.plugin.lifecycle import PluginStartupLease, plugin_lifecycle
|
||||
from app.application.plugin.source import (
|
||||
Candidate,
|
||||
CandidateInventory,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
PluginSelection,
|
||||
get_effective_local_candidate,
|
||||
list_effective_online_candidates,
|
||||
select_plugin_candidate,
|
||||
)
|
||||
|
||||
InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]]
|
||||
IdentityReader = Callable[[str], Awaitable[PluginIdentity | None]]
|
||||
CandidateCompatibility = Callable[[Candidate], tuple[bool, str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginSourceInspection:
|
||||
"""前端与 Agent 选择来源所需的只读候选和当前身份快照。"""
|
||||
|
||||
plugin_id: str
|
||||
inventory_complete: bool
|
||||
identity: PluginIdentity | None
|
||||
selection: PluginSelection
|
||||
online_candidates: tuple[PluginMarketCandidate, ...]
|
||||
local_candidate: PluginLocalCandidate | None
|
||||
|
||||
|
||||
class PluginInstallExecutor(Protocol):
|
||||
"""统一 Gateway 调用的可恢复安装执行端口。"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
admission: PluginInstallAdmission,
|
||||
release_version: str | None,
|
||||
force: bool,
|
||||
local_sync: bool = False,
|
||||
) -> PluginInstallResult:
|
||||
"""执行已通过来源准入的插件载荷事务。"""
|
||||
|
||||
|
||||
class PluginInstallGateway:
|
||||
"""让全部插件载荷写入共享同一来源策略和事务执行器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
inventory: InventoryProvider,
|
||||
identity: IdentityReader,
|
||||
candidate_compatibility: CandidateCompatibility,
|
||||
executor: PluginInstallExecutor,
|
||||
clock: Callable[[], datetime],
|
||||
) -> None:
|
||||
"""保存候选事实、身份读取、兼容校验、事务执行和时间端口。"""
|
||||
self.__inventory = inventory
|
||||
self.__identity = identity
|
||||
self.__candidate_compatibility = candidate_compatibility
|
||||
self.__executor = executor
|
||||
self.__clock = clock
|
||||
|
||||
async def install(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: str | None,
|
||||
package_version: str | None = None,
|
||||
release_version: str | None = None,
|
||||
force: bool = False,
|
||||
explicit_source: bool = False,
|
||||
source_change: bool = False,
|
||||
expected_revision: int | None = None,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
local_sync: bool = False,
|
||||
) -> PluginInstallResult:
|
||||
"""读取冻结库存并执行一次不能绕过来源身份的插件写入。"""
|
||||
try:
|
||||
inventory = await self.__inventory(force)
|
||||
async with plugin_lifecycle.hold(plugin_id, startup_token):
|
||||
identity = await self.__identity(plugin_id)
|
||||
admission = admit_plugin_install(
|
||||
inventory,
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id=plugin_id,
|
||||
generations=_generation_order(package_version),
|
||||
requested_repo_url=repo_url,
|
||||
explicit_source=explicit_source,
|
||||
source_change=source_change,
|
||||
expected_revision=expected_revision,
|
||||
),
|
||||
identity=identity,
|
||||
now=self.__clock(),
|
||||
)
|
||||
compatible, message = self.__candidate_compatibility(
|
||||
admission.candidate
|
||||
)
|
||||
if not compatible:
|
||||
raise PluginSourceAdmissionError(
|
||||
message or "插件候选与当前 MoviePilot 版本不兼容"
|
||||
)
|
||||
return await self.__executor.execute(
|
||||
admission=admission,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
)
|
||||
except (TypeError, ValueError, PluginSourceAdmissionError) as error:
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=str(error),
|
||||
failure_stage="source_admission",
|
||||
)
|
||||
|
||||
async def inspect_source(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
package_version: str | None = None,
|
||||
force: bool = False,
|
||||
) -> PluginSourceInspection:
|
||||
"""读取与真实安装相同的库存和身份,返回脱敏来源选择快照。"""
|
||||
inventory = await self.__inventory(force)
|
||||
identity = await self.__identity(plugin_id)
|
||||
generations = _generation_order(package_version)
|
||||
selection = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
generations=generations,
|
||||
identity=identity,
|
||||
)
|
||||
return PluginSourceInspection(
|
||||
plugin_id=plugin_id,
|
||||
inventory_complete=inventory.complete,
|
||||
identity=identity,
|
||||
selection=selection,
|
||||
online_candidates=list_effective_online_candidates(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
generations=generations,
|
||||
),
|
||||
local_candidate=get_effective_local_candidate(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
generations=generations,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_plugin_install_gateway: PluginInstallGateway | None = None
|
||||
|
||||
|
||||
def configure_plugin_install_service(gateway: PluginInstallGateway) -> None:
|
||||
"""由启动组合根发布当前 lifespan 的唯一插件安装 Gateway。"""
|
||||
global _plugin_install_gateway
|
||||
_plugin_install_gateway = gateway
|
||||
|
||||
|
||||
def get_plugin_install_service() -> PluginInstallGateway:
|
||||
"""返回已装配 Gateway;启动未完成时拒绝任何载荷写入。"""
|
||||
if _plugin_install_gateway is None:
|
||||
raise RuntimeError("插件安装服务尚未完成初始化")
|
||||
return _plugin_install_gateway
|
||||
|
||||
|
||||
def reset_plugin_install_service() -> None:
|
||||
"""清除当前 lifespan 的 Gateway,供停机和隔离测试使用。"""
|
||||
global _plugin_install_gateway
|
||||
_plugin_install_gateway = None
|
||||
|
||||
|
||||
def _generation_order(package_version: str | None) -> tuple[str, ...]:
|
||||
"""把兼容入口的首选代际转换为来源选择优先序。"""
|
||||
normalized = (package_version or "v3").strip().lower()
|
||||
if normalized in {"", "v1"}:
|
||||
return ("v1",)
|
||||
if normalized == "v2":
|
||||
return ("v2", "v1")
|
||||
if normalized == "v3":
|
||||
return PLUGIN_V3_GENERATIONS
|
||||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@@ -295,7 +296,7 @@ def plan_legacy_plugin_identity(
|
||||
trusted_key = None
|
||||
basis = PluginBindingBasis.LEGACY_UNBOUND
|
||||
bound_at = None
|
||||
if market_availability is PluginMarketAvailability.AVAILABLE and official:
|
||||
if official:
|
||||
trusted_type = TrustedPluginSourceType.OFFICIAL
|
||||
trusted_key = official[0].source_key
|
||||
basis = PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
@@ -349,6 +350,21 @@ class PluginIdentityRepository(Protocol):
|
||||
"""按 revision 条件暂存替换,并返回是否赢得竞争。"""
|
||||
|
||||
|
||||
class PluginIdentityStore(Protocol):
|
||||
"""组合根注入的独立来源身份读取与存量迁移端口。"""
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""首次创建或按 revision 更新身份。"""
|
||||
|
||||
|
||||
class PluginIdentityUnitOfWork(Protocol):
|
||||
"""来源身份条件写使用的事务端口。"""
|
||||
|
||||
@@ -431,3 +447,275 @@ class WritePluginIdentityCommand:
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class ChangePluginIdentitySourceCommand:
|
||||
"""以独立 CAS 合同提交一次明确的在线插件来源转换。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""只允许已有身份按精确 revision 切换到不同在线来源。"""
|
||||
return _execute_identity_transition(
|
||||
self._repository,
|
||||
self._unit_of_work,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
validate=_validate_identity_source_change,
|
||||
)
|
||||
|
||||
|
||||
class BindOnlinePluginIdentityCommand:
|
||||
"""以独立 CAS 合同为未绑定身份建立在线可信来源。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""只允许未绑定身份按精确 revision 首次绑定在线来源。"""
|
||||
return _execute_identity_transition(
|
||||
self._repository,
|
||||
self._unit_of_work,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
validate=_validate_online_binding,
|
||||
)
|
||||
|
||||
|
||||
class BindLocalPluginIdentityCommand:
|
||||
"""以独立 CAS 合同把存量未绑定身份转换为本地专属身份。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""只允许 legacy_unbound 身份按精确 revision 绑定本地载荷。"""
|
||||
return _execute_identity_transition(
|
||||
self._repository,
|
||||
self._unit_of_work,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
validate=_validate_local_binding,
|
||||
)
|
||||
|
||||
|
||||
def _execute_identity_transition(
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
validate: Callable[[PluginIdentity, PluginIdentity], None],
|
||||
) -> PluginIdentity:
|
||||
"""在一个数据库事务中校验并提交专用身份转换。"""
|
||||
try:
|
||||
current = _prepare_identity_transition(
|
||||
repository,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
validate(current, identity)
|
||||
candidate = replace(
|
||||
identity,
|
||||
normalized_plugin_id=current.normalized_plugin_id,
|
||||
revision=current.revision + 1,
|
||||
created_at=current.created_at,
|
||||
)
|
||||
_stage_identity_transition(
|
||||
repository,
|
||||
unit_of_work,
|
||||
candidate,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
return candidate
|
||||
except Exception:
|
||||
unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _prepare_identity_transition(
|
||||
repository: PluginIdentityRepository,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""读取转换基线并保证目标使用同一物理插件和精确 revision。"""
|
||||
if expected_revision < 1:
|
||||
raise PluginIdentityConflictError("插件来源身份 expected_revision 必须从 1 开始")
|
||||
normalized_plugin_id = normalize_physical_plugin_id(identity.plugin_id)
|
||||
current = repository.get(normalized_plugin_id)
|
||||
if current is None or current.revision != expected_revision:
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {identity.plugin_id} 的来源身份 revision 已被其他任务更新"
|
||||
)
|
||||
if (
|
||||
identity.plugin_id != current.plugin_id
|
||||
or identity.normalized_plugin_id != current.normalized_plugin_id
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"插件来源转换不能改变物理插件 ID"
|
||||
)
|
||||
if identity.created_at != current.created_at:
|
||||
raise PluginIdentityConflictError(
|
||||
"插件来源转换必须保留身份创建时间"
|
||||
)
|
||||
if identity.updated_at < current.updated_at:
|
||||
raise PluginIdentityConflictError(
|
||||
"插件身份更新时间不能早于已提交记录"
|
||||
)
|
||||
return current
|
||||
|
||||
|
||||
def _validate_identity_source_change(
|
||||
current: PluginIdentity,
|
||||
candidate: PluginIdentity,
|
||||
) -> None:
|
||||
"""校验显式换源的来源、载荷和实际变化边界。"""
|
||||
if candidate.binding_basis is not PluginBindingBasis.EXPLICIT_SOURCE_CHANGE:
|
||||
raise PluginIdentityConflictError(
|
||||
"显式换源目标必须使用 explicit_source_change 依据"
|
||||
)
|
||||
if candidate.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginIdentityConflictError("显式换源目标必须是在线可信来源")
|
||||
if candidate.payload_source_type not in {
|
||||
PluginPayloadSourceType.OFFICIAL,
|
||||
PluginPayloadSourceType.THIRD_PARTY,
|
||||
}:
|
||||
raise PluginIdentityConflictError("显式换源目标必须携带在线载荷")
|
||||
if (
|
||||
candidate.trusted_source_type.value != candidate.payload_source_type.value
|
||||
or candidate.trusted_source_key != candidate.payload_source_key
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"显式换源目标的 trusted 与 payload 来源必须一致"
|
||||
)
|
||||
if (
|
||||
current.trusted_source_type is candidate.trusted_source_type
|
||||
and current.trusted_source_key == candidate.trusted_source_key
|
||||
):
|
||||
raise PluginIdentityConflictError("显式换源的实际来源必须变化")
|
||||
|
||||
|
||||
def _validate_online_binding(
|
||||
current: PluginIdentity,
|
||||
candidate: PluginIdentity,
|
||||
) -> None:
|
||||
"""校验未绑定身份首次建立在线可信来源的转换边界。"""
|
||||
if (
|
||||
current.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
or current.binding_basis not in {
|
||||
PluginBindingBasis.LEGACY_UNBOUND,
|
||||
PluginBindingBasis.LOCAL_ONLY,
|
||||
}
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"在线绑定只允许当前未绑定的存量或本地身份"
|
||||
)
|
||||
if candidate.binding_basis not in {
|
||||
PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
PluginBindingBasis.TOFU,
|
||||
PluginBindingBasis.EXPLICIT_INSTALL,
|
||||
}:
|
||||
raise PluginIdentityConflictError(
|
||||
"在线绑定目标必须说明官方、TOFU 或显式安装依据"
|
||||
)
|
||||
if candidate.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise PluginIdentityConflictError("在线绑定目标必须携带可信来源")
|
||||
if candidate.payload_source_type is PluginPayloadSourceType.UNKNOWN:
|
||||
if (
|
||||
current.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
|
||||
or candidate.binding_basis not in {
|
||||
PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
PluginBindingBasis.TOFU,
|
||||
}
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"仅存量未知来源身份可在不声明载荷来源时建立默认在线绑定"
|
||||
)
|
||||
return
|
||||
if candidate.payload_source_type not in {
|
||||
PluginPayloadSourceType.OFFICIAL,
|
||||
PluginPayloadSourceType.THIRD_PARTY,
|
||||
}:
|
||||
raise PluginIdentityConflictError("在线绑定目标必须携带在线载荷")
|
||||
if (
|
||||
candidate.trusted_source_type.value != candidate.payload_source_type.value
|
||||
or candidate.trusted_source_key != candidate.payload_source_key
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"在线绑定目标的 trusted 与 payload 来源必须一致"
|
||||
)
|
||||
|
||||
|
||||
def _validate_local_binding(
|
||||
current: PluginIdentity,
|
||||
candidate: PluginIdentity,
|
||||
) -> None:
|
||||
"""校验存量未绑定身份到本地身份的唯一转换方向。"""
|
||||
if (
|
||||
current.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
or current.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"本地绑定只允许当前 unknown + legacy_unbound 身份"
|
||||
)
|
||||
if (
|
||||
candidate.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
or candidate.binding_basis is not PluginBindingBasis.LOCAL_ONLY
|
||||
or candidate.payload_source_type is not PluginPayloadSourceType.LOCAL
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"本地绑定目标必须是 unknown + local_only 且携带本地载荷"
|
||||
)
|
||||
|
||||
|
||||
def _stage_identity_transition(
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
candidate: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
"""按数据库 revision 条件暂存转换并提交事务。"""
|
||||
if not repository.stage_replace(
|
||||
candidate,
|
||||
expected_revision=expected_revision,
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {candidate.plugin_id} 的来源身份 revision 已被其他任务更新"
|
||||
)
|
||||
unit_of_work.commit()
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""存量插件来源身份的一次性启动迁移。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
PluginMarketAvailability,
|
||||
PluginSourceCandidate,
|
||||
TrustedPluginSourceType,
|
||||
normalize_physical_plugin_id,
|
||||
plan_legacy_plugin_identity,
|
||||
)
|
||||
from app.application.plugin.source import CandidateInventory
|
||||
from app.runtime.log import logger
|
||||
|
||||
InventoryProvider = Callable[[bool], Awaitable[CandidateInventory]]
|
||||
InstalledPluginsReader = Callable[[], list[str]]
|
||||
VirtualInstancePredicate = Callable[[str], bool]
|
||||
|
||||
|
||||
class PluginIdentityMigrationPersistence(Protocol):
|
||||
"""存量来源迁移所需的最小异步持久化端口。"""
|
||||
|
||||
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的当前身份。"""
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""创建尚不存在的存量身份。"""
|
||||
|
||||
async def bind_online_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""把 legacy_unbound 身份绑定到已确认的在线来源。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginIdentityMigrationResult:
|
||||
"""一次迁移批次创建、绑定和跳过的物理插件数量。"""
|
||||
|
||||
created: int = 0
|
||||
bound: int = 0
|
||||
unbound: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
class PluginIdentityMigrationService:
|
||||
"""在任何自动更新前为已安装物理插件建立最小来源身份。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
persistence: PluginIdentityMigrationPersistence,
|
||||
inventory: InventoryProvider,
|
||||
installed_plugins: InstalledPluginsReader,
|
||||
is_virtual_instance: VirtualInstancePredicate,
|
||||
clock: Callable[[], datetime],
|
||||
) -> None:
|
||||
"""保存库存、安装清单、虚拟实例判定和 CAS 端口。"""
|
||||
self.__persistence = persistence
|
||||
self.__inventory = inventory
|
||||
self.__installed_plugins = installed_plugins
|
||||
self.__is_virtual_instance = is_virtual_instance
|
||||
self.__clock = clock
|
||||
|
||||
async def migrate(self) -> PluginIdentityMigrationResult:
|
||||
"""幂等迁移全部存量身份;数据库异常会阻止后续自动更新。"""
|
||||
inventory = await self.__inventory(False)
|
||||
created = 0
|
||||
bound = 0
|
||||
unbound = 0
|
||||
skipped = 0
|
||||
seen: set[str] = set()
|
||||
|
||||
for plugin_id in self.__installed_plugins() or []:
|
||||
try:
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
except ValueError as error:
|
||||
logger.warning("跳过插件 %s 的存量来源迁移:%s", plugin_id, error)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if normalized_id in seen or self.__is_virtual_instance(plugin_id):
|
||||
skipped += 1
|
||||
continue
|
||||
seen.add(normalized_id)
|
||||
candidates = inventory.candidates_for(plugin_id)
|
||||
planned = plan_legacy_plugin_identity(
|
||||
plugin_id=plugin_id,
|
||||
market_availability=(
|
||||
PluginMarketAvailability.AVAILABLE
|
||||
if inventory.can_use_for_tofu
|
||||
else PluginMarketAvailability.UNAVAILABLE
|
||||
),
|
||||
online_candidates=tuple(
|
||||
PluginSourceCandidate(
|
||||
source_type=candidate.source_type,
|
||||
source_key=candidate.source_key,
|
||||
)
|
||||
for candidate in candidates
|
||||
),
|
||||
is_virtual_instance=False,
|
||||
now=self.__clock(),
|
||||
)
|
||||
existing = await self.__persistence.get_identity(normalized_id)
|
||||
|
||||
if planned is None:
|
||||
skipped += 1
|
||||
continue
|
||||
if existing is None:
|
||||
try:
|
||||
migrated = await self.__persistence.migrate_identity(
|
||||
planned,
|
||||
expected_revision=None,
|
||||
)
|
||||
except PluginIdentityConflictError:
|
||||
if await self.__persistence.get_identity(plugin_id) is None:
|
||||
raise
|
||||
skipped += 1
|
||||
continue
|
||||
created += 1
|
||||
if migrated.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
unbound += 1
|
||||
else:
|
||||
bound += 1
|
||||
continue
|
||||
if (
|
||||
existing.binding_basis is not PluginBindingBasis.LEGACY_UNBOUND
|
||||
or planned.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
target = replace(
|
||||
planned,
|
||||
plugin_id=existing.plugin_id,
|
||||
normalized_plugin_id=existing.normalized_plugin_id,
|
||||
revision=existing.revision + 1,
|
||||
created_at=existing.created_at,
|
||||
updated_at=self.__clock(),
|
||||
)
|
||||
try:
|
||||
await self.__persistence.bind_online_identity(
|
||||
target,
|
||||
expected_revision=existing.revision,
|
||||
)
|
||||
except PluginIdentityConflictError:
|
||||
current = await self.__persistence.get_identity(plugin_id)
|
||||
if current is None or current.revision == existing.revision:
|
||||
raise
|
||||
skipped += 1
|
||||
continue
|
||||
bound += 1
|
||||
|
||||
result = PluginIdentityMigrationResult(
|
||||
created=created,
|
||||
bound=bound,
|
||||
unbound=unbound,
|
||||
skipped=skipped,
|
||||
)
|
||||
logger.info(
|
||||
"插件来源身份迁移完成:创建=%s,已绑定=%s,未绑定=%s,跳过=%s",
|
||||
result.created,
|
||||
result.bound,
|
||||
result.unbound,
|
||||
result.skipped,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_IDENTITY_MIGRATION_SERVICE: list[PluginIdentityMigrationService] = []
|
||||
|
||||
|
||||
def configure_plugin_identity_migration(
|
||||
service: PluginIdentityMigrationService,
|
||||
) -> None:
|
||||
"""由组合根登记当前 lifespan 的存量身份迁移服务。"""
|
||||
_IDENTITY_MIGRATION_SERVICE.clear()
|
||||
_IDENTITY_MIGRATION_SERVICE.append(service)
|
||||
|
||||
|
||||
def get_plugin_identity_migration() -> PluginIdentityMigrationService:
|
||||
"""返回已装配迁移服务;缺失时拒绝绕过来源迁移。"""
|
||||
if not _IDENTITY_MIGRATION_SERVICE:
|
||||
raise RuntimeError("插件来源身份迁移服务尚未完成初始化")
|
||||
return _IDENTITY_MIGRATION_SERVICE[0]
|
||||
|
||||
|
||||
def reset_plugin_identity_migration() -> None:
|
||||
"""清除当前 lifespan 的迁移服务,供测试和停机复位。"""
|
||||
_IDENTITY_MIGRATION_SERVICE.clear()
|
||||
+646
-378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,430 @@
|
||||
"""插件市场候选库存读取与外部事实映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
OFFICIAL_PLUGIN_SOURCE_KEY,
|
||||
TrustedPluginSourceType,
|
||||
validate_online_source_key,
|
||||
)
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
LocalCandidateRead,
|
||||
MarketRead,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
normalize_package_generation,
|
||||
)
|
||||
|
||||
PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1")
|
||||
PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]]
|
||||
PluginIndexLoaderResult: TypeAlias = PluginIndex | None
|
||||
LocalCandidateLoadPayload: TypeAlias = (
|
||||
Mapping[str, Mapping[str, Any]]
|
||||
| Iterable[Mapping[str, Any]]
|
||||
| None
|
||||
)
|
||||
MarketLoader: TypeAlias = Callable[
|
||||
[str, str | None, bool],
|
||||
PluginIndexLoaderResult,
|
||||
]
|
||||
AsyncMarketLoader: TypeAlias = Callable[
|
||||
[str, str | None, bool],
|
||||
Awaitable[PluginIndexLoaderResult],
|
||||
]
|
||||
LocalCandidateLoader: TypeAlias = Callable[
|
||||
[],
|
||||
LocalCandidateLoadPayload,
|
||||
]
|
||||
|
||||
|
||||
class PluginCandidateInventoryReader:
|
||||
"""按配置市场和 V3 代际顺序保留全部候选及读取终态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
async_market_loader: AsyncMarketLoader | None = None,
|
||||
generations: Sequence[str] = PLUGIN_V3_GENERATIONS,
|
||||
max_concurrency: int = 12,
|
||||
) -> None:
|
||||
"""保存读取端口,并限制异步市场请求的进程内并发。"""
|
||||
normalized_generations = tuple(
|
||||
normalize_package_generation(generation)
|
||||
for generation in generations
|
||||
)
|
||||
if normalized_generations != PLUGIN_V3_GENERATIONS:
|
||||
raise ValueError("V3 候选库存必须按 v3、v2、v1 顺序读取")
|
||||
if max_concurrency < 1:
|
||||
raise ValueError("插件市场读取并发必须大于 0")
|
||||
self._market_loader = market_loader
|
||||
self._async_market_loader = async_market_loader
|
||||
self._local_candidate_loader = local_candidate_loader
|
||||
self._generations = normalized_generations
|
||||
self._max_concurrency = max_concurrency
|
||||
|
||||
def load(
|
||||
self,
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""同步读取全部配置市场,不把失败市场伪装成空仓库。"""
|
||||
normalized_markets = _normalize_markets(markets)
|
||||
reads = tuple(
|
||||
self._read_market_generation(market, generation, force=force)
|
||||
for market in normalized_markets
|
||||
for generation in self._generations
|
||||
)
|
||||
local_read = self._load_local_candidates()
|
||||
return CandidateInventory(
|
||||
reads,
|
||||
local_read.candidates,
|
||||
local_read=local_read,
|
||||
expected_markets=normalized_markets,
|
||||
expected_generations=self._generations,
|
||||
)
|
||||
|
||||
async def async_load(
|
||||
self,
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""有界并发读取全部市场,同时保持配置与代际的稳定顺序。"""
|
||||
normalized_markets = _normalize_markets(markets)
|
||||
semaphore = asyncio.Semaphore(self._max_concurrency)
|
||||
|
||||
async def read(market: str, generation: str) -> MarketRead:
|
||||
async with semaphore:
|
||||
return await self._async_read_market_generation(
|
||||
market,
|
||||
generation,
|
||||
force=force,
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
read(market, generation),
|
||||
name="plugin.inventory.read",
|
||||
)
|
||||
for market in normalized_markets
|
||||
for generation in self._generations
|
||||
]
|
||||
try:
|
||||
reads = tuple(await asyncio.gather(*tasks)) if tasks else ()
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
local_read = await asyncio.to_thread(self._load_local_candidates)
|
||||
return CandidateInventory(
|
||||
reads,
|
||||
local_read.candidates,
|
||||
local_read=local_read,
|
||||
expected_markets=normalized_markets,
|
||||
expected_generations=self._generations,
|
||||
)
|
||||
|
||||
def _read_market_generation(
|
||||
self,
|
||||
market: str,
|
||||
generation: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> MarketRead:
|
||||
"""同步读取一个市场代际并映射为候选事实。"""
|
||||
try:
|
||||
source_key, repo_url, source_type = _market_source(market)
|
||||
payload = self._market_loader(
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
return _successful_read(
|
||||
market,
|
||||
generation,
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return MarketRead.failure(
|
||||
market,
|
||||
_error_message(error),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
async def _async_read_market_generation(
|
||||
self,
|
||||
market: str,
|
||||
generation: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> MarketRead:
|
||||
"""异步读取一个市场代际并映射为候选事实。"""
|
||||
try:
|
||||
source_key, repo_url, source_type = _market_source(market)
|
||||
if self._async_market_loader is None:
|
||||
payload = await asyncio.to_thread(
|
||||
self._market_loader,
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
else:
|
||||
payload = await self._async_market_loader(
|
||||
repo_url,
|
||||
_package_version(generation),
|
||||
force,
|
||||
)
|
||||
return _successful_read(
|
||||
market,
|
||||
generation,
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return MarketRead.failure(
|
||||
market,
|
||||
_error_message(error),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
def _load_local_candidates(self) -> LocalCandidateRead:
|
||||
"""映射本地候选,并保留扫描失败而非伪装为空仓库。"""
|
||||
if self._local_candidate_loader is None:
|
||||
return LocalCandidateRead.absent()
|
||||
try:
|
||||
raw_candidates = self._local_candidate_loader()
|
||||
if raw_candidates is None:
|
||||
return LocalCandidateRead.failure(
|
||||
"本地插件仓库读取未返回可判定结果"
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 失败事实必须进入快照
|
||||
return LocalCandidateRead.failure(_error_message(error))
|
||||
entries: Iterable[tuple[object, Mapping[str, Any]]]
|
||||
try:
|
||||
if isinstance(raw_candidates, Mapping):
|
||||
entries = raw_candidates.items()
|
||||
else:
|
||||
entries = (
|
||||
(plugin_info.get("id"), plugin_info)
|
||||
for plugin_info in raw_candidates
|
||||
if isinstance(plugin_info, Mapping)
|
||||
)
|
||||
|
||||
candidates: list[PluginLocalCandidate] = []
|
||||
for plugin_id, plugin_info in entries:
|
||||
if not isinstance(plugin_id, str) or not isinstance(plugin_info, Mapping):
|
||||
continue
|
||||
try:
|
||||
candidate = _local_candidate(plugin_id, plugin_info)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
except Exception as error: # noqa: BLE001 - 迭代或映射失败也要保留状态
|
||||
return LocalCandidateRead.failure(_error_message(error))
|
||||
return LocalCandidateRead.present(candidates)
|
||||
|
||||
|
||||
def build_plugin_candidate_inventory(
|
||||
markets: Iterable[str],
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
force: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""使用注入的同步读取端口构建一次候选库存。"""
|
||||
return PluginCandidateInventoryReader(
|
||||
market_loader=market_loader,
|
||||
local_candidate_loader=local_candidate_loader,
|
||||
).load(markets, force=force)
|
||||
|
||||
|
||||
def normalize_github_plugin_source(value: str) -> tuple[str, str]:
|
||||
"""把 GitHub 仓库 URL 或来源键归一为持久来源键和公开地址。"""
|
||||
normalized = str(value).strip().rstrip("/")
|
||||
if normalized.lower().startswith("github:"):
|
||||
source_key = validate_online_source_key(normalized)
|
||||
owner, repository = source_key.removeprefix("github:").split("/", 1)
|
||||
return source_key, f"https://github.com/{owner}/{repository}"
|
||||
|
||||
parsed = urlsplit(normalized)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
|
||||
raise ValueError("插件市场必须是 GitHub 仓库地址")
|
||||
if parsed.hostname.lower() != "github.com":
|
||||
raise ValueError("插件市场必须使用 github.com 仓库地址")
|
||||
parts = [unquote(part) for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
raise ValueError("插件市场 GitHub 地址缺少 owner 或 repository")
|
||||
owner, repository = parts[:2]
|
||||
repository = repository.removesuffix(".git")
|
||||
source_key = validate_online_source_key(f"github:{owner}/{repository}")
|
||||
return source_key, f"https://github.com/{owner}/{repository}"
|
||||
|
||||
|
||||
def _normalize_markets(markets: Iterable[str]) -> tuple[str, ...]:
|
||||
"""规范化并去除重复市场,保留无效配置供读取快照报错。"""
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for market in markets:
|
||||
value = str(market).strip().rstrip("/")
|
||||
if not value:
|
||||
continue
|
||||
key = value.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(value)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _market_source(
|
||||
market: str,
|
||||
) -> tuple[str, str, TrustedPluginSourceType]:
|
||||
"""解析 GitHub 市场来源,并固定官方仓库分类。"""
|
||||
source_key, repo_url = normalize_github_plugin_source(market)
|
||||
source_type = (
|
||||
TrustedPluginSourceType.OFFICIAL
|
||||
if source_key == OFFICIAL_PLUGIN_SOURCE_KEY
|
||||
else TrustedPluginSourceType.THIRD_PARTY
|
||||
)
|
||||
return source_key, repo_url, source_type
|
||||
|
||||
|
||||
def _successful_read(
|
||||
market: str,
|
||||
generation: str,
|
||||
payload: PluginIndexLoaderResult,
|
||||
*,
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType,
|
||||
repo_url: str,
|
||||
) -> MarketRead:
|
||||
"""把 Adapter 读取结果映射为一个市场代际的三态事实。"""
|
||||
if payload is None:
|
||||
return MarketRead.absent(
|
||||
market,
|
||||
package_generation=generation,
|
||||
)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise TypeError("插件市场索引必须是对象")
|
||||
return MarketRead.present(
|
||||
market,
|
||||
_market_candidates(
|
||||
payload,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
),
|
||||
package_generation=generation,
|
||||
)
|
||||
|
||||
|
||||
def _market_candidates(
|
||||
payload: PluginIndex,
|
||||
*,
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType,
|
||||
repo_url: str,
|
||||
package_generation: str,
|
||||
) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""过滤并映射一个代际索引中的 V3 可兼容条目。"""
|
||||
result: list[PluginMarketCandidate] = []
|
||||
for index_plugin_id, raw_info in payload.items():
|
||||
if not isinstance(raw_info, Mapping):
|
||||
continue
|
||||
plugin_id = raw_info.get("id") or index_plugin_id
|
||||
if not isinstance(plugin_id, str):
|
||||
continue
|
||||
if not _is_v3_compatible(raw_info, package_generation):
|
||||
continue
|
||||
plugin_version = raw_info.get("version")
|
||||
if plugin_version is None:
|
||||
plugin_version = raw_info.get("plugin_version")
|
||||
if plugin_version == "":
|
||||
plugin_version = None
|
||||
try:
|
||||
result.append(
|
||||
PluginMarketCandidate(
|
||||
plugin_id=plugin_id,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=package_generation,
|
||||
plugin_version=plugin_version,
|
||||
dto=dict(raw_info),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _is_v3_compatible(
|
||||
plugin_info: Mapping[str, Any],
|
||||
package_generation: str,
|
||||
) -> bool:
|
||||
"""按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。"""
|
||||
if plugin_info.get("v3") is False:
|
||||
return False
|
||||
if package_generation in {"v3", "v2"}:
|
||||
return True
|
||||
return plugin_info.get("v3") is True or plugin_info.get("v2") is True
|
||||
|
||||
|
||||
def _local_candidate(
|
||||
plugin_id: str,
|
||||
plugin_info: Mapping[str, Any],
|
||||
) -> PluginLocalCandidate | None:
|
||||
"""把本地插件索引条目转换为应用候选。"""
|
||||
generation = normalize_package_generation(
|
||||
str(
|
||||
plugin_info.get("package_version")
|
||||
or plugin_info.get("package_generation")
|
||||
or "v1"
|
||||
)
|
||||
)
|
||||
if not _is_v3_compatible(plugin_info, generation):
|
||||
return None
|
||||
repo_url = plugin_info.get("repo_url")
|
||||
if not isinstance(repo_url, str) or not repo_url.startswith("local://"):
|
||||
return None
|
||||
plugin_version = plugin_info.get("version")
|
||||
if plugin_version is None:
|
||||
plugin_version = plugin_info.get("plugin_version")
|
||||
if plugin_version == "":
|
||||
plugin_version = None
|
||||
return PluginLocalCandidate(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
plugin_version=plugin_version,
|
||||
dto=dict(plugin_info),
|
||||
)
|
||||
|
||||
|
||||
def _package_version(package_generation: str) -> str | None:
|
||||
"""把公共代际转换为市场索引文件参数。"""
|
||||
return None if package_generation == "v1" else package_generation
|
||||
|
||||
|
||||
def _error_message(error: Exception) -> str:
|
||||
"""保留可诊断的读取失败说明,并避免空异常丢失状态。"""
|
||||
message = str(error).strip()
|
||||
return message or error.__class__.__name__
|
||||
@@ -4,9 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class PluginStartupLease:
|
||||
"""启动 lease 的不透明能力句柄,仅按对象身份由所属协调器认可。"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
class PluginLifecycleCoordinator:
|
||||
"""在事件循环和同步启动线程之间协调插件生命周期操作。"""
|
||||
|
||||
@@ -14,17 +21,29 @@ class PluginLifecycleCoordinator:
|
||||
self._condition = threading.Condition()
|
||||
self._active_plugins: set[str] = set()
|
||||
self._startup_active = False
|
||||
self._startup_token: PluginStartupLease | None = None
|
||||
|
||||
@staticmethod
|
||||
def _normalize(plugin_id: str) -> str:
|
||||
return (plugin_id or "").strip().lower()
|
||||
|
||||
def _try_acquire_plugin(self, plugin_id: str) -> bool:
|
||||
def _try_acquire_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> bool:
|
||||
normalized_id = self._normalize(plugin_id)
|
||||
if not normalized_id:
|
||||
raise ValueError("插件ID不能为空")
|
||||
with self._condition:
|
||||
if self._startup_active or normalized_id in self._active_plugins:
|
||||
startup_token_matches = (
|
||||
startup_token is not None and startup_token is self._startup_token
|
||||
)
|
||||
# 启动期间只有当前 lease 的显式 token 可以取得逐插件资格。
|
||||
if (
|
||||
normalized_id in self._active_plugins
|
||||
or (self._startup_active and not startup_token_matches)
|
||||
):
|
||||
return False
|
||||
self._active_plugins.add(normalized_id)
|
||||
return True
|
||||
@@ -35,22 +54,32 @@ class PluginLifecycleCoordinator:
|
||||
self._active_plugins.discard(normalized_id)
|
||||
self._condition.notify_all()
|
||||
|
||||
def _try_acquire_startup(self) -> bool:
|
||||
def _try_acquire_startup(self) -> PluginStartupLease | None:
|
||||
with self._condition:
|
||||
if self._startup_active or self._active_plugins:
|
||||
return False
|
||||
return None
|
||||
startup_token = PluginStartupLease()
|
||||
self._startup_active = True
|
||||
return True
|
||||
self._startup_token = startup_token
|
||||
return startup_token
|
||||
|
||||
def _release_startup(self) -> None:
|
||||
def _release_startup(self, startup_token: PluginStartupLease) -> None:
|
||||
with self._condition:
|
||||
# 延迟清理不得释放已经由新 owner 持有的启动 lease。
|
||||
if self._startup_token is not startup_token:
|
||||
return
|
||||
self._startup_token = None
|
||||
self._startup_active = False
|
||||
self._condition.notify_all()
|
||||
|
||||
@asynccontextmanager
|
||||
async def hold(self, plugin_id: str):
|
||||
async def hold(
|
||||
self,
|
||||
plugin_id: str,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> AsyncIterator[None]:
|
||||
"""异步持有单个插件的生命周期资格,不在线程池中等待锁。"""
|
||||
while not self._try_acquire_plugin(plugin_id):
|
||||
while not self._try_acquire_plugin(plugin_id, startup_token):
|
||||
await asyncio.sleep(0.01)
|
||||
try:
|
||||
yield
|
||||
@@ -58,14 +87,18 @@ class PluginLifecycleCoordinator:
|
||||
self._release_plugin(plugin_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def hold_startup(self):
|
||||
async def hold_startup(self) -> AsyncIterator[PluginStartupLease]:
|
||||
"""异步持有启动同步的全局资格,阻止安装请求穿过启动收口。"""
|
||||
while not self._try_acquire_startup():
|
||||
startup_token: PluginStartupLease | None = None
|
||||
while startup_token is None:
|
||||
startup_token = self._try_acquire_startup()
|
||||
if startup_token is not None:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
try:
|
||||
yield
|
||||
yield startup_token
|
||||
finally:
|
||||
self._release_startup()
|
||||
self._release_startup(startup_token)
|
||||
|
||||
|
||||
plugin_lifecycle = PluginLifecycleCoordinator()
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""插件安装 journal 的启动恢复与已提交事务收尾。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.install import (
|
||||
PluginPackageCheckpoint,
|
||||
PluginPackageTransactionPort,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
PluginPersistenceService,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
|
||||
class PluginInstallationRecoveryError(RuntimeError):
|
||||
"""恢复材料不足或已提交载荷事实不一致,不能继续导入插件。"""
|
||||
|
||||
|
||||
class PluginRecoveryPackagePort(PluginPackageTransactionPort, Protocol):
|
||||
"""启动恢复在安装包事务端口之上需要的重建与核验能力。"""
|
||||
|
||||
def restore_checkpoint(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
transaction_id: str,
|
||||
plugin_existed: bool,
|
||||
persistent_backup_existed: bool,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""只根据 journal 中的受限事实重建恢复路径。"""
|
||||
|
||||
async def async_committed_payload_receipt(
|
||||
self,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> str:
|
||||
"""读取当前部署模式下一次已提交载荷的恢复收据。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallationRecoveryResult:
|
||||
"""启动恢复批次的 PREPARED 回滚和 COMMITTED 收尾数量。"""
|
||||
|
||||
restored: int = 0
|
||||
finalized: int = 0
|
||||
cleanup_pending: int = 0
|
||||
|
||||
|
||||
class PluginInstallationRecoveryService:
|
||||
"""在插件导入前把跨进程 journal 收敛到完整旧状态或新状态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
persistence: PluginPersistenceService,
|
||||
packages: PluginRecoveryPackagePort,
|
||||
) -> None:
|
||||
"""保存数据库最终事实和文件恢复端口。"""
|
||||
self.__persistence = persistence
|
||||
self.__packages = packages
|
||||
|
||||
async def replay(self) -> PluginInstallationRecoveryResult:
|
||||
"""按创建顺序恢复全部 journal;关键事实不一致时阻止插件启动。"""
|
||||
restored = 0
|
||||
finalized = 0
|
||||
cleanup_pending = 0
|
||||
for record in await self.__persistence.list_installations():
|
||||
checkpoint = self.__checkpoint(record)
|
||||
if record.phase is PluginInstallationPhase.PREPARED:
|
||||
await self.__restore_prepared(record, checkpoint)
|
||||
restored += 1
|
||||
continue
|
||||
if await self.__finish_committed(record, checkpoint):
|
||||
finalized += 1
|
||||
else:
|
||||
cleanup_pending += 1
|
||||
return PluginInstallationRecoveryResult(
|
||||
restored=restored,
|
||||
finalized=finalized,
|
||||
cleanup_pending=cleanup_pending,
|
||||
)
|
||||
|
||||
def __checkpoint(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
) -> PluginPackageCheckpoint:
|
||||
"""把 journal 映射为受控恢复路径,不读取任意持久化路径。"""
|
||||
return self.__packages.restore_checkpoint(
|
||||
plugin_id=record.plugin_id,
|
||||
transaction_id=record.transaction_id,
|
||||
plugin_existed=record.package_existed,
|
||||
persistent_backup_existed=record.persistent_backup_existed,
|
||||
)
|
||||
|
||||
async def __restore_prepared(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> None:
|
||||
"""恢复数据库提交前的文件状态,再释放 journal 所有权。"""
|
||||
try:
|
||||
await self.__packages.async_restore(checkpoint)
|
||||
await self.__persistence.delete_installation(
|
||||
record.transaction_id,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
except Exception as error:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的未提交安装恢复失败:{error}"
|
||||
) from error
|
||||
try:
|
||||
await self.__packages.async_cleanup(checkpoint)
|
||||
except Exception as error: # journal 已删除,孤儿材料不改变业务终态
|
||||
logger.warning(
|
||||
"插件安装事务 %s 已恢复,但恢复材料清理失败:%s",
|
||||
record.transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
async def __finish_committed(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
checkpoint: PluginPackageCheckpoint,
|
||||
) -> bool:
|
||||
"""核验并收尾已提交载荷;非关键清理失败留待下一次启动。"""
|
||||
identity = await self.__persistence.get_identity(record.plugin_id)
|
||||
if identity is None or identity.revision != record.identity_target_revision:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的已提交身份与安装 journal 不一致"
|
||||
)
|
||||
try:
|
||||
receipt = await self.__packages.async_committed_payload_receipt(
|
||||
checkpoint
|
||||
)
|
||||
except Exception as error:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的已提交载荷无法核验:{error}"
|
||||
) from error
|
||||
if receipt != identity.payload_receipt:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的已提交载荷收据不一致"
|
||||
)
|
||||
try:
|
||||
await self.__packages.async_finalize_persistent_backup(checkpoint)
|
||||
except Exception as error:
|
||||
raise PluginInstallationRecoveryError(
|
||||
f"插件 {record.plugin_id} 的持久备份终态不完整:{error}"
|
||||
) from error
|
||||
try:
|
||||
await self.__packages.async_commit(checkpoint)
|
||||
await self.__persistence.delete_installation(
|
||||
record.transaction_id,
|
||||
expected_phase=PluginInstallationPhase.COMMITTED,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"插件安装事务 %s 已提交但收尾仍待重试:%s",
|
||||
record.transaction_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_RECOVERY_SERVICE: list[PluginInstallationRecoveryService] = []
|
||||
|
||||
|
||||
def configure_plugin_installation_recovery(
|
||||
service: PluginInstallationRecoveryService,
|
||||
) -> None:
|
||||
"""由组合根登记当前 lifespan 的安装恢复服务。"""
|
||||
_RECOVERY_SERVICE.clear()
|
||||
_RECOVERY_SERVICE.append(service)
|
||||
|
||||
|
||||
def get_plugin_installation_recovery() -> PluginInstallationRecoveryService:
|
||||
"""返回已装配恢复服务;启动顺序错误时拒绝跳过恢复。"""
|
||||
if not _RECOVERY_SERVICE:
|
||||
raise RuntimeError("插件安装恢复服务尚未完成初始化")
|
||||
return _RECOVERY_SERVICE[0]
|
||||
|
||||
|
||||
def reset_plugin_installation_recovery() -> None:
|
||||
"""清除当前 lifespan 的恢复服务。"""
|
||||
_RECOVERY_SERVICE.clear()
|
||||
@@ -0,0 +1,824 @@
|
||||
"""插件市场候选事实与来源选择策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
normalize_physical_plugin_id,
|
||||
validate_online_source_key,
|
||||
)
|
||||
from app.application.plugin.identity import (
|
||||
PluginSourceCandidate as IdentitySourceCandidate,
|
||||
)
|
||||
from app.foundation.version import compare_version
|
||||
|
||||
PLUGIN_GENERATIONS = ("v1", "v2", "v3")
|
||||
|
||||
|
||||
class MarketReadStatus(StrEnum):
|
||||
"""一次市场索引读取的最终状态。"""
|
||||
|
||||
PRESENT = "present"
|
||||
ABSENT = "absent"
|
||||
FAILED = "failed"
|
||||
|
||||
class PluginSelectionStatus(StrEnum):
|
||||
"""插件候选选择的可观察结果。"""
|
||||
|
||||
SELECTED = "selected"
|
||||
UNAVAILABLE = "unavailable"
|
||||
CONFLICT = "conflict"
|
||||
INCOMPLETE = "incomplete"
|
||||
|
||||
|
||||
class PluginSourceSelectionError(RuntimeError):
|
||||
"""插件来源选择的策略错误。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginMarketCandidate:
|
||||
"""一个在线市场条目的原始候选事实。"""
|
||||
|
||||
plugin_id: str
|
||||
source_key: str
|
||||
source_type: TrustedPluginSourceType
|
||||
repo_url: str
|
||||
package_generation: str
|
||||
plugin_version: str | None
|
||||
dto: Any = None
|
||||
normalized_plugin_id: str = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验候选身份,并把外部来源键归一为持久化合同使用的形式。"""
|
||||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||||
source_type = _coerce_online_source_type(self.source_type)
|
||||
source_key = validate_online_source_key(self.source_key)
|
||||
# IdentitySourceCandidate 复用官方仓库与来源类型的双向约束。
|
||||
IdentitySourceCandidate(source_type=source_type, source_key=source_key)
|
||||
repo_url = _normalize_repo_url(self.repo_url)
|
||||
package_generation = normalize_package_generation(self.package_generation)
|
||||
plugin_version = _normalize_plugin_version(self.plugin_version)
|
||||
object.__setattr__(self, "normalized_plugin_id", normalized_id)
|
||||
object.__setattr__(self, "source_type", source_type)
|
||||
object.__setattr__(self, "source_key", source_key)
|
||||
object.__setattr__(self, "repo_url", repo_url)
|
||||
object.__setattr__(self, "package_generation", package_generation)
|
||||
object.__setattr__(self, "plugin_version", plugin_version)
|
||||
|
||||
@property
|
||||
def payload_source_type(self) -> PluginPayloadSourceType:
|
||||
"""返回用于载荷审计的在线来源类型。"""
|
||||
return PluginPayloadSourceType(self.source_type.value)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成不携带原始元数据的公共候选投影。"""
|
||||
return {
|
||||
"plugin_id": self.plugin_id,
|
||||
"source_key": self.source_key,
|
||||
"source_type": self.source_type.value,
|
||||
"repo_url": self.repo_url,
|
||||
"package_generation": self.package_generation,
|
||||
"plugin_version": self.plugin_version,
|
||||
}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginLocalCandidate:
|
||||
"""一个本地插件载荷候选,与在线来源身份保持独立。"""
|
||||
|
||||
plugin_id: str
|
||||
repo_url: str
|
||||
package_generation: str
|
||||
plugin_version: str | None
|
||||
dto: Any = None
|
||||
normalized_plugin_id: str = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验本地载荷的插件与版本事实。"""
|
||||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||||
repo_url = _normalize_repo_url(self.repo_url)
|
||||
package_generation = normalize_package_generation(self.package_generation)
|
||||
plugin_version = _normalize_plugin_version(self.plugin_version)
|
||||
object.__setattr__(self, "normalized_plugin_id", normalized_id)
|
||||
object.__setattr__(self, "repo_url", repo_url)
|
||||
object.__setattr__(self, "package_generation", package_generation)
|
||||
object.__setattr__(self, "plugin_version", plugin_version)
|
||||
|
||||
@property
|
||||
def payload_source_type(self) -> PluginPayloadSourceType:
|
||||
"""返回本地载荷类型,不把本地路径伪装成在线来源。"""
|
||||
return PluginPayloadSourceType.LOCAL
|
||||
|
||||
@property
|
||||
def source_type(self) -> PluginPayloadSourceType:
|
||||
"""返回独立的本地载荷类型,不伪造在线可信来源。"""
|
||||
return PluginPayloadSourceType.LOCAL
|
||||
|
||||
@property
|
||||
def source_key(self) -> None:
|
||||
"""本地载荷没有可绑定的在线来源键。"""
|
||||
return None
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成本地候选的公共投影,永不暴露仓库路径或原始 metadata。"""
|
||||
return {
|
||||
"plugin_id": self.plugin_id,
|
||||
"source_type": PluginPayloadSourceType.LOCAL.value,
|
||||
"package_generation": self.package_generation,
|
||||
"plugin_version": self.plugin_version,
|
||||
}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketRead:
|
||||
"""记录一个配置市场的读取状态及其全部在线候选。"""
|
||||
|
||||
market: str
|
||||
status: MarketReadStatus
|
||||
candidates: tuple[PluginMarketCandidate, ...] = ()
|
||||
error: str | None = None
|
||||
package_generation: str = "v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝把失败读取伪装成空成功结果。"""
|
||||
market = _normalize_market(self.market)
|
||||
status = MarketReadStatus(self.status)
|
||||
candidates = tuple(self.candidates)
|
||||
package_generation = normalize_package_generation(self.package_generation)
|
||||
if status is MarketReadStatus.FAILED:
|
||||
if candidates:
|
||||
raise ValueError("失败的插件市场读取不能携带候选")
|
||||
if not self.error or not self.error.strip():
|
||||
raise ValueError("失败的插件市场读取必须保留错误说明")
|
||||
else:
|
||||
if self.error:
|
||||
raise ValueError("已判定的插件市场读取不能携带错误说明")
|
||||
if status is MarketReadStatus.ABSENT and candidates:
|
||||
raise ValueError("不存在的插件市场索引不能携带候选")
|
||||
if any(not isinstance(candidate, PluginMarketCandidate) for candidate in candidates):
|
||||
raise TypeError("市场读取候选必须是在线插件候选")
|
||||
object.__setattr__(self, "market", market)
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "candidates", candidates)
|
||||
object.__setattr__(self, "error", self.error.strip() if self.error else None)
|
||||
object.__setattr__(self, "package_generation", package_generation)
|
||||
|
||||
@classmethod
|
||||
def present(
|
||||
cls,
|
||||
market: str,
|
||||
candidates: Iterable[PluginMarketCandidate] = (),
|
||||
*,
|
||||
package_generation: str = "v1",
|
||||
) -> "MarketRead":
|
||||
"""构造存在的索引读取;真实空索引可以没有候选。"""
|
||||
return cls(
|
||||
market=market,
|
||||
status=MarketReadStatus.PRESENT,
|
||||
candidates=tuple(candidates),
|
||||
package_generation=package_generation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def absent(
|
||||
cls,
|
||||
market: str,
|
||||
*,
|
||||
package_generation: str = "v1",
|
||||
) -> "MarketRead":
|
||||
"""构造已确认不存在的代际索引。"""
|
||||
return cls(
|
||||
market=market,
|
||||
status=MarketReadStatus.ABSENT,
|
||||
package_generation=package_generation,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failure(
|
||||
cls,
|
||||
market: str,
|
||||
error: str,
|
||||
*,
|
||||
package_generation: str = "v1",
|
||||
) -> "MarketRead":
|
||||
"""构造失败读取,并保留可诊断但不用于选择的错误说明。"""
|
||||
return cls(
|
||||
market=market,
|
||||
status=MarketReadStatus.FAILED,
|
||||
error=error,
|
||||
package_generation=package_generation,
|
||||
)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
"""判断该索引是否得到存在或不存在的确定结论。"""
|
||||
return self.status is not MarketReadStatus.FAILED
|
||||
|
||||
@property
|
||||
def present_index(self) -> bool:
|
||||
"""判断该代际索引是否真实存在。"""
|
||||
return self.status is MarketReadStatus.PRESENT
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成市场读取的脱敏投影,保留状态、代际和候选事实。"""
|
||||
return {
|
||||
"market": self.market,
|
||||
"package_generation": self.package_generation,
|
||||
"status": self.status.value,
|
||||
"error": self.error,
|
||||
"candidates": [candidate.public_dict() for candidate in self.candidates],
|
||||
}
|
||||
|
||||
|
||||
class LocalCandidateReadStatus(StrEnum):
|
||||
"""一次本地插件仓库扫描的可观察终态。"""
|
||||
|
||||
PRESENT = "present"
|
||||
ABSENT = "absent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalCandidateRead:
|
||||
"""记录本地候选扫描状态,避免扫描失败伪装成空仓库。"""
|
||||
|
||||
status: LocalCandidateReadStatus
|
||||
candidates: tuple[PluginLocalCandidate, ...] = ()
|
||||
error: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""保证本地扫描状态、候选和错误说明相互一致。"""
|
||||
status = LocalCandidateReadStatus(self.status)
|
||||
candidates = tuple(self.candidates)
|
||||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in candidates):
|
||||
raise TypeError("本地扫描候选必须是 PluginLocalCandidate")
|
||||
if status is LocalCandidateReadStatus.FAILED:
|
||||
if candidates:
|
||||
raise ValueError("失败的本地扫描不能携带候选")
|
||||
if not self.error or not self.error.strip():
|
||||
raise ValueError("失败的本地扫描必须保留错误说明")
|
||||
else:
|
||||
if self.error:
|
||||
raise ValueError("已判定的本地扫描不能携带错误说明")
|
||||
if status is LocalCandidateReadStatus.ABSENT and candidates:
|
||||
raise ValueError("不存在的本地扫描不能携带候选")
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "candidates", candidates)
|
||||
object.__setattr__(self, "error", self.error.strip() if self.error else None)
|
||||
|
||||
@classmethod
|
||||
def present(
|
||||
cls,
|
||||
candidates: Iterable[PluginLocalCandidate] = (),
|
||||
) -> "LocalCandidateRead":
|
||||
"""构造扫描成功的本地候选快照,空结果仍表示扫描成功。"""
|
||||
return cls(
|
||||
status=LocalCandidateReadStatus.PRESENT,
|
||||
candidates=tuple(candidates),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def absent(cls) -> "LocalCandidateRead":
|
||||
"""构造没有配置本地仓库的结果。"""
|
||||
return cls(status=LocalCandidateReadStatus.ABSENT)
|
||||
|
||||
@classmethod
|
||||
def failure(cls, error: str) -> "LocalCandidateRead":
|
||||
"""构造无法完成本地扫描的结果。"""
|
||||
return cls(status=LocalCandidateReadStatus.FAILED, error=error)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成不泄漏本地路径的扫描投影。"""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"error": self.error,
|
||||
"candidates": [candidate.public_dict() for candidate in self.candidates],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateInventory:
|
||||
"""一次短生命周期市场快照,保留配置市场状态和全部候选。"""
|
||||
|
||||
market_reads: tuple[MarketRead, ...]
|
||||
local_candidates: tuple[PluginLocalCandidate, ...] = ()
|
||||
expected_markets: tuple[str, ...] | None = None
|
||||
expected_generations: tuple[str, ...] | None = None
|
||||
local_read: LocalCandidateRead | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""冻结快照输入,避免后续市场刷新改变选择依据。"""
|
||||
market_reads = tuple(self.market_reads)
|
||||
local_candidates = tuple(self.local_candidates)
|
||||
expected_markets = (
|
||||
tuple(_normalize_market(market) for market in self.expected_markets)
|
||||
if self.expected_markets is not None
|
||||
else None
|
||||
)
|
||||
expected_generations = (
|
||||
tuple(normalize_package_generation(generation) for generation in self.expected_generations)
|
||||
if self.expected_generations is not None
|
||||
else None
|
||||
)
|
||||
local_read = self.local_read
|
||||
if local_read is None:
|
||||
local_read = (
|
||||
LocalCandidateRead.present(local_candidates)
|
||||
if local_candidates
|
||||
else LocalCandidateRead.absent()
|
||||
)
|
||||
if not isinstance(local_read, LocalCandidateRead):
|
||||
raise TypeError("候选清单的本地读取必须由 LocalCandidateRead 组成")
|
||||
if local_read.candidates != local_candidates:
|
||||
raise ValueError("候选清单的本地读取与本地候选必须一致")
|
||||
if any(not isinstance(read, MarketRead) for read in market_reads):
|
||||
raise TypeError("候选清单必须由 MarketRead 组成")
|
||||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local_candidates):
|
||||
raise TypeError("本地候选清单必须由 PluginLocalCandidate 组成")
|
||||
read_keys = [(read.market, read.package_generation) for read in market_reads]
|
||||
if len(read_keys) != len(set(read_keys)):
|
||||
raise ValueError("候选清单不能重复记录同一个市场代际")
|
||||
if expected_markets is not None and len(expected_markets) != len(set(expected_markets)):
|
||||
raise ValueError("候选清单的预期市场不能重复")
|
||||
if expected_generations is not None:
|
||||
if not expected_generations or len(expected_generations) != len(set(expected_generations)):
|
||||
raise ValueError("候选清单的预期代际必须唯一且非空")
|
||||
object.__setattr__(self, "market_reads", market_reads)
|
||||
object.__setattr__(self, "local_candidates", local_candidates)
|
||||
object.__setattr__(self, "expected_markets", expected_markets)
|
||||
object.__setattr__(self, "expected_generations", expected_generations)
|
||||
object.__setattr__(self, "local_read", local_read)
|
||||
|
||||
@property
|
||||
def configured_markets(self) -> tuple[str, ...]:
|
||||
"""按配置顺序返回本轮预期读取的市场。"""
|
||||
if self.expected_markets is not None:
|
||||
return self.expected_markets
|
||||
return tuple(dict.fromkeys(read.market for read in self.market_reads))
|
||||
|
||||
def reads_for(self, market: str) -> tuple[MarketRead, ...]:
|
||||
"""返回一个市场的全部代际读取事实。"""
|
||||
normalized_market = _normalize_market(market)
|
||||
return tuple(read for read in self.market_reads if read.market == normalized_market)
|
||||
|
||||
def read_for(self, market: str, package_generation: str) -> MarketRead | None:
|
||||
"""返回一个市场和代际的读取事实。"""
|
||||
normalized_generation = normalize_package_generation(package_generation)
|
||||
return next(
|
||||
(
|
||||
read
|
||||
for read in self.reads_for(market)
|
||||
if read.package_generation == normalized_generation
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""只有预期市场与代际均可证明已成功读取时才算完整。"""
|
||||
if not self.market_reads or not all(read.succeeded for read in self.market_reads):
|
||||
return False
|
||||
expected_markets = self.expected_markets
|
||||
expected_generations = self.expected_generations
|
||||
if (expected_markets is None) != (expected_generations is None):
|
||||
return False
|
||||
if expected_markets is None or expected_generations is None:
|
||||
return True
|
||||
expected = {
|
||||
(market, generation)
|
||||
for market in expected_markets
|
||||
for generation in expected_generations
|
||||
}
|
||||
actual = {(read.market, read.package_generation) for read in self.market_reads}
|
||||
return expected <= actual
|
||||
|
||||
@property
|
||||
def can_use_for_tofu(self) -> bool:
|
||||
"""判断快照是否足以证明唯一第三方来源。"""
|
||||
local_read = self.local_read
|
||||
return (
|
||||
self.complete
|
||||
and local_read is not None
|
||||
and local_read.status is not LocalCandidateReadStatus.FAILED
|
||||
)
|
||||
|
||||
@property
|
||||
def online_candidates(self) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""按配置市场顺序返回全部在线候选,不按 ID 或版本去重。"""
|
||||
return tuple(
|
||||
candidate
|
||||
for read in self.market_reads
|
||||
if read.present_index
|
||||
for candidate in read.candidates
|
||||
)
|
||||
|
||||
def candidates_for(self, plugin_id: str) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""读取一个插件 ID 的全部在线候选。"""
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
return tuple(
|
||||
candidate
|
||||
for candidate in self.online_candidates
|
||||
if candidate.normalized_plugin_id == normalized_id
|
||||
)
|
||||
|
||||
def local_candidates_for(self, plugin_id: str) -> tuple[PluginLocalCandidate, ...]:
|
||||
"""读取一个插件 ID 的全部本地候选。"""
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
return tuple(
|
||||
candidate
|
||||
for candidate in self.local_candidates
|
||||
if candidate.normalized_plugin_id == normalized_id
|
||||
)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成完整库存的脱敏投影,不泄漏本地路径或原始 DTO。"""
|
||||
local_read = self.local_read
|
||||
if local_read is None:
|
||||
raise RuntimeError("候选清单缺少本地读取终态")
|
||||
return {
|
||||
"markets": [read.public_dict() for read in self.market_reads],
|
||||
"local_candidates": [candidate.public_dict() for candidate in self.local_candidates],
|
||||
"local_read": local_read.public_dict(),
|
||||
"complete": self.complete,
|
||||
}
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginSelection:
|
||||
"""候选选择结果,冲突和不完整状态均不降级为静默空值。"""
|
||||
|
||||
status: PluginSelectionStatus
|
||||
candidate: PluginMarketCandidate | PluginLocalCandidate | None = None
|
||||
conflict_source_keys: tuple[str, ...] = ()
|
||||
reason: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""保证选择状态与载荷及冲突信息相互一致。"""
|
||||
status = PluginSelectionStatus(self.status)
|
||||
conflict_source_keys = tuple(sorted(set(self.conflict_source_keys)))
|
||||
if status is PluginSelectionStatus.SELECTED and self.candidate is None:
|
||||
raise ValueError("selected 结果必须携带候选")
|
||||
if status is not PluginSelectionStatus.SELECTED and self.candidate is not None:
|
||||
raise ValueError("未选中结果不能携带候选")
|
||||
if status is not PluginSelectionStatus.CONFLICT and conflict_source_keys:
|
||||
raise ValueError("只有 conflict 结果能携带冲突来源")
|
||||
object.__setattr__(self, "status", status)
|
||||
object.__setattr__(self, "conflict_source_keys", conflict_source_keys)
|
||||
|
||||
@property
|
||||
def selected(self) -> bool:
|
||||
"""判断是否已经选择出一个载荷候选。"""
|
||||
return self.status is PluginSelectionStatus.SELECTED
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
"""生成安全的选择结果投影,不透传本地路径或原始 DTO。"""
|
||||
result: dict[str, Any] = {
|
||||
"status": self.status.value,
|
||||
"reason": self.reason,
|
||||
}
|
||||
if self.conflict_source_keys:
|
||||
result["conflict_source_keys"] = list(self.conflict_source_keys)
|
||||
if self.candidate is not None:
|
||||
result["candidate"] = self.candidate.public_dict()
|
||||
return result
|
||||
|
||||
Candidate: TypeAlias = PluginMarketCandidate | PluginLocalCandidate
|
||||
|
||||
|
||||
def normalize_package_generation(package_generation: str) -> str:
|
||||
"""校验并归一插件包代际。"""
|
||||
value = str(package_generation).strip().lower()
|
||||
if value not in PLUGIN_GENERATIONS:
|
||||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|
||||
return value
|
||||
|
||||
|
||||
def _select_local_candidate(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
normalized_id: str,
|
||||
generation_order: tuple[str, ...],
|
||||
local_candidates: Iterable[PluginLocalCandidate] | None,
|
||||
) -> PluginSelection | None:
|
||||
"""优先选择本地载荷;读取失败时阻止自动降级到在线来源。"""
|
||||
local = (
|
||||
tuple(local_candidates)
|
||||
if local_candidates is not None
|
||||
else inventory.local_candidates_for(plugin_id)
|
||||
)
|
||||
if any(not isinstance(candidate, PluginLocalCandidate) for candidate in local):
|
||||
raise TypeError("本地候选必须是 PluginLocalCandidate")
|
||||
if any(candidate.normalized_plugin_id != normalized_id for candidate in local):
|
||||
raise ValueError("本地候选的插件 ID 必须与选择目标一致")
|
||||
local_read = inventory.local_read
|
||||
if (
|
||||
not local
|
||||
and local_read is not None
|
||||
and local_read.status is LocalCandidateReadStatus.FAILED
|
||||
):
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="本地插件仓库读取失败,不能自动选择在线载荷",
|
||||
)
|
||||
if not local:
|
||||
return None
|
||||
selected_local = _select_best(local, generation_order)
|
||||
if selected_local is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="本地候选没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_local,
|
||||
reason="优先使用本地载荷",
|
||||
)
|
||||
|
||||
|
||||
def select_plugin_candidate(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
identity: PluginIdentity | None = None,
|
||||
local_candidates: Iterable[PluginLocalCandidate] | None = None,
|
||||
requested_source_key: str | None = None,
|
||||
explicit_source: bool = False,
|
||||
allow_source_change: bool = False,
|
||||
) -> PluginSelection:
|
||||
"""
|
||||
按允许来源、运行代际和同源版本选择一个插件载荷。
|
||||
|
||||
:param inventory: 本轮市场读取快照
|
||||
:param plugin_id: 要选择的物理插件 ID
|
||||
:param generations: 调用方按优先级传入的代际顺序
|
||||
:param identity: 已安装插件来源身份;为空表示未安装
|
||||
:param local_candidates: 可选的本地载荷候选,优先于在线候选
|
||||
:param requested_source_key: 调用方提供的规范在线来源;非显式调用不能绕过本地载荷
|
||||
:param explicit_source: 本次调用是否代表管理员明确选源
|
||||
:param allow_source_change: 是否是带 revision 的显式换源命令
|
||||
:return: 带明确冲突或不完整状态的选择结果
|
||||
"""
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
generation_order = _normalize_generation_order(generations)
|
||||
requested_source = (
|
||||
validate_online_source_key(requested_source_key)
|
||||
if requested_source_key is not None
|
||||
else None
|
||||
)
|
||||
if requested_source is None or not (explicit_source or allow_source_change):
|
||||
local_selection = _select_local_candidate(
|
||||
inventory,
|
||||
plugin_id=plugin_id,
|
||||
normalized_id=normalized_id,
|
||||
generation_order=generation_order,
|
||||
local_candidates=local_candidates,
|
||||
)
|
||||
if local_selection is not None:
|
||||
return local_selection
|
||||
|
||||
online = inventory.candidates_for(plugin_id)
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason=f"没有找到插件 {plugin_id} 的在线候选",
|
||||
)
|
||||
|
||||
allowed_source = _allowed_source(identity, normalized_id)
|
||||
if requested_source is not None:
|
||||
requested_online = tuple(
|
||||
candidate
|
||||
for candidate in online
|
||||
if candidate.source_key == requested_source
|
||||
)
|
||||
if not requested_online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="明确选择的在线来源没有当前插件候选",
|
||||
)
|
||||
if allowed_source is not None:
|
||||
_source_type, allowed_key = allowed_source
|
||||
if requested_source != allowed_key and not allow_source_change:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.CONFLICT,
|
||||
conflict_source_keys=(allowed_key, requested_source),
|
||||
reason="普通安装不能改变已绑定的在线来源",
|
||||
)
|
||||
if explicit_source or allow_source_change:
|
||||
selected_requested = _select_best(requested_online, generation_order)
|
||||
if selected_requested is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="明确选择的来源没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_requested,
|
||||
reason=(
|
||||
"按显式换源目标选择在线载荷"
|
||||
if allow_source_change
|
||||
else "按管理员明确选择的来源安装在线载荷"
|
||||
),
|
||||
)
|
||||
if allowed_source is not None:
|
||||
source_type, source_key = allowed_source
|
||||
online = tuple(
|
||||
candidate
|
||||
for candidate in online
|
||||
if candidate.source_type is source_type and candidate.source_key == source_key
|
||||
)
|
||||
if not online:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="当前来源身份没有可用候选",
|
||||
)
|
||||
selected_online = _select_best(online, generation_order)
|
||||
if selected_online is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="在线候选没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_online,
|
||||
reason="按已绑定来源选择在线载荷",
|
||||
)
|
||||
|
||||
if identity is not None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="插件来源身份尚未绑定,不能自动选择在线载荷",
|
||||
)
|
||||
|
||||
source_pairs = {(candidate.source_type, candidate.source_key) for candidate in online}
|
||||
if len(source_pairs) > 1:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.CONFLICT,
|
||||
conflict_source_keys=tuple(source_key for _source_type, source_key in source_pairs),
|
||||
reason="未安装插件存在多个在线来源,不能静默选择",
|
||||
)
|
||||
|
||||
source_type = next(iter(source_pairs))[0]
|
||||
if source_type is TrustedPluginSourceType.THIRD_PARTY and not inventory.can_use_for_tofu:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.INCOMPLETE,
|
||||
reason="市场读取不完整,不能建立唯一第三方来源的 TOFU",
|
||||
)
|
||||
selected_online = _select_best(online, generation_order)
|
||||
if selected_online is None:
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.UNAVAILABLE,
|
||||
reason="在线候选没有符合当前运行代际的版本",
|
||||
)
|
||||
return PluginSelection(
|
||||
status=PluginSelectionStatus.SELECTED,
|
||||
candidate=selected_online,
|
||||
reason="唯一在线来源候选",
|
||||
)
|
||||
|
||||
|
||||
def list_effective_online_candidates(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""按来源列出当前运行代际实际可安装的最高版本候选。"""
|
||||
generation_order = _normalize_generation_order(generations)
|
||||
grouped: dict[
|
||||
tuple[TrustedPluginSourceType, str],
|
||||
list[PluginMarketCandidate],
|
||||
] = {}
|
||||
for candidate in inventory.candidates_for(plugin_id):
|
||||
grouped.setdefault(
|
||||
(candidate.source_type, candidate.source_key),
|
||||
[],
|
||||
).append(candidate)
|
||||
|
||||
selected: list[PluginMarketCandidate] = []
|
||||
for candidates in grouped.values():
|
||||
selected_candidate = _select_best(candidates, generation_order)
|
||||
if isinstance(selected_candidate, PluginMarketCandidate):
|
||||
selected.append(selected_candidate)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def get_effective_local_candidate(
|
||||
inventory: CandidateInventory,
|
||||
*,
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
) -> PluginLocalCandidate | None:
|
||||
"""返回本地插件目录中当前运行代际优先级最高的安全候选。"""
|
||||
candidate = _select_best(
|
||||
inventory.local_candidates_for(plugin_id),
|
||||
_normalize_generation_order(generations),
|
||||
)
|
||||
return candidate if isinstance(candidate, PluginLocalCandidate) else None
|
||||
|
||||
|
||||
def parse_local_plugin_reference(repo_url: str) -> str | None:
|
||||
"""从不透明本地来源标识中提取插件 ID,不读取或暴露宿主路径。"""
|
||||
if not str(repo_url).startswith("local://"):
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(repo_url)
|
||||
plugin_id = unquote(parsed.netloc or parsed.path.strip("/"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return plugin_id or None
|
||||
|
||||
|
||||
def _coerce_online_source_type(source_type: TrustedPluginSourceType) -> TrustedPluginSourceType:
|
||||
"""把外部字符串来源类型转换为可信在线来源枚举。"""
|
||||
value = TrustedPluginSourceType(source_type)
|
||||
if value is TrustedPluginSourceType.UNKNOWN:
|
||||
raise ValueError("未知来源不能作为在线市场候选")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_market(market: str) -> str:
|
||||
"""校验市场标识,保留其作为本轮快照的显示值。"""
|
||||
value = str(market).strip()
|
||||
if not value:
|
||||
raise ValueError("插件市场标识不能为空")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_repo_url(repo_url: str) -> str:
|
||||
"""保留仓库地址作为安装事实,但移除无意义的外围空白。"""
|
||||
value = str(repo_url).strip()
|
||||
if not value:
|
||||
raise ValueError("插件仓库地址不能为空")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_plugin_version(plugin_version: str | None) -> str | None:
|
||||
"""标准化可选插件声明版本,并保持缺失版本可观察。"""
|
||||
if plugin_version is None:
|
||||
return None
|
||||
value = str(plugin_version).strip()
|
||||
if not value:
|
||||
raise ValueError("插件声明版本不能为空字符串")
|
||||
if len(value) > 64:
|
||||
raise ValueError("插件声明版本长度不能超过 64")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_generation_order(generations: Sequence[str]) -> tuple[str, ...]:
|
||||
"""校验调用方提供的代际优先序,并拒绝重复项。"""
|
||||
normalized = tuple(normalize_package_generation(generation) for generation in generations)
|
||||
if not normalized:
|
||||
raise ValueError("至少需要一个当前运行代际")
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise ValueError("当前运行代际优先序不能重复")
|
||||
return normalized
|
||||
|
||||
|
||||
def _allowed_source(
|
||||
identity: PluginIdentity | None,
|
||||
normalized_plugin_id: str,
|
||||
) -> tuple[TrustedPluginSourceType, str] | None:
|
||||
"""从已安装身份读取不可变的允许在线来源。"""
|
||||
if identity is None:
|
||||
return None
|
||||
if identity.normalized_plugin_id != normalized_plugin_id:
|
||||
raise ValueError("来源身份的插件 ID 与选择目标不一致")
|
||||
if identity.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
return None
|
||||
if not identity.trusted_source_key:
|
||||
raise PluginSourceSelectionError("已绑定来源身份缺少规范来源键")
|
||||
return identity.trusted_source_type, identity.trusted_source_key
|
||||
|
||||
|
||||
def _select_best(
|
||||
candidates: Sequence[Candidate],
|
||||
generation_order: Sequence[str],
|
||||
) -> Candidate | None:
|
||||
"""在已完成来源过滤后按代际和同源版本选择最高候选。"""
|
||||
for generation in generation_order:
|
||||
generation_candidates = tuple(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if candidate.package_generation == generation
|
||||
)
|
||||
if generation_candidates:
|
||||
return _select_highest_version(generation_candidates)
|
||||
return None
|
||||
|
||||
|
||||
def _select_highest_version(candidates: Sequence[Candidate]) -> Candidate:
|
||||
"""使用宿主既有版本比较语义选择同源最高版本,平级保留先读候选。"""
|
||||
selected = candidates[0]
|
||||
for candidate in candidates[1:]:
|
||||
selected_version = selected.plugin_version or "0"
|
||||
candidate_version = candidate.plugin_version or "0"
|
||||
if compare_version(candidate_version, ">", selected_version):
|
||||
selected = candidate
|
||||
return selected
|
||||
@@ -0,0 +1,325 @@
|
||||
"""插件安装事务的持久化端口与可逆状态记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PluginInstallationPhase(StrEnum):
|
||||
"""安装事务在持久化协调器中的两个数据库阶段。"""
|
||||
|
||||
PREPARED = "prepared"
|
||||
COMMITTED = "committed"
|
||||
|
||||
|
||||
class PluginInstallationConflictError(RuntimeError):
|
||||
"""事务不存在、阶段竞争或实际状态发生漂移。"""
|
||||
|
||||
|
||||
class PluginInstallationRecordError(ValueError):
|
||||
"""事务记录不符合可持久化和恢复合同。"""
|
||||
|
||||
|
||||
_TRANSACTION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
INSTALLATION_JOURNAL_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _validate_revision(value: int | None, *, field_name: str) -> None:
|
||||
"""校验身份 CAS revision;``None`` 表示对应身份行不存在。"""
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise PluginInstallationRecordError(
|
||||
f"{field_name} 必须是大于等于 1 的整数或 null"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallationRecord:
|
||||
"""可跨进程恢复的插件安装事务状态。"""
|
||||
|
||||
transaction_id: str
|
||||
plugin_id: str
|
||||
phase: PluginInstallationPhase
|
||||
membership_before: bool
|
||||
membership_target: bool | None
|
||||
identity_before_revision: int | None
|
||||
identity_target_revision: int | None
|
||||
package_existed: bool
|
||||
persistent_backup_existed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
schema_version: int = INSTALLATION_JOURNAL_SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝不能作为 CAS 或崩溃恢复依据的事务记录。"""
|
||||
if not _TRANSACTION_ID_PATTERN.fullmatch(self.transaction_id):
|
||||
raise PluginInstallationRecordError("transaction_id 格式不合法")
|
||||
if not self.plugin_id or self.plugin_id != self.plugin_id.strip():
|
||||
raise PluginInstallationRecordError("plugin_id 不能为空或带首尾空格")
|
||||
if not isinstance(self.phase, PluginInstallationPhase):
|
||||
try:
|
||||
object.__setattr__(self, "phase", PluginInstallationPhase(self.phase))
|
||||
except ValueError as error:
|
||||
raise PluginInstallationRecordError("未知安装事务 phase") from error
|
||||
|
||||
if not isinstance(self.membership_before, bool):
|
||||
raise PluginInstallationRecordError("membership_before 必须是布尔值")
|
||||
if not isinstance(self.package_existed, bool):
|
||||
raise PluginInstallationRecordError("package_existed 必须是布尔值")
|
||||
if not isinstance(self.persistent_backup_existed, bool):
|
||||
raise PluginInstallationRecordError(
|
||||
"persistent_backup_existed 必须是布尔值"
|
||||
)
|
||||
if self.membership_target is not None and not isinstance(
|
||||
self.membership_target,
|
||||
bool,
|
||||
):
|
||||
raise PluginInstallationRecordError("membership_target 必须是布尔值或 null")
|
||||
if (
|
||||
self.phase is PluginInstallationPhase.COMMITTED
|
||||
and self.membership_target is None
|
||||
):
|
||||
raise PluginInstallationRecordError(
|
||||
"COMMITTED 事务必须包含 membership target"
|
||||
)
|
||||
|
||||
_validate_revision(
|
||||
self.identity_before_revision,
|
||||
field_name="identity_before_revision",
|
||||
)
|
||||
_validate_revision(
|
||||
self.identity_target_revision,
|
||||
field_name="identity_target_revision",
|
||||
)
|
||||
if self.created_at.tzinfo is None or self.updated_at.tzinfo is None:
|
||||
raise PluginInstallationRecordError("事务时间必须包含时区")
|
||||
if self.updated_at < self.created_at:
|
||||
raise PluginInstallationRecordError("事务更新时间不能早于创建时间")
|
||||
if (
|
||||
isinstance(self.schema_version, bool)
|
||||
or not isinstance(self.schema_version, int)
|
||||
or self.schema_version != INSTALLATION_JOURNAL_SCHEMA_VERSION
|
||||
):
|
||||
raise PluginInstallationRecordError(
|
||||
f"不支持的插件安装事务快照版本: {self.schema_version}"
|
||||
)
|
||||
|
||||
|
||||
class PluginInstallationStore(Protocol):
|
||||
"""安装 Gateway 使用的同步持久化端口。"""
|
||||
|
||||
def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord:
|
||||
"""创建一条 PREPARED 安装事务。"""
|
||||
|
||||
def get(self, transaction_id: str) -> PluginInstallationRecord | None:
|
||||
"""按事务 ID 读取记录。"""
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
) -> list[PluginInstallationRecord]:
|
||||
"""列出事务记录。"""
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在 PREPARED 阶段登记目标 membership 和身份 revision。"""
|
||||
|
||||
def commit_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在同一同步事务中完成身份、membership 和 COMMITTED phase。"""
|
||||
|
||||
def delete(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase CAS 删除已处理的事务记录。"""
|
||||
|
||||
|
||||
class PluginIdentityPersistence(Protocol):
|
||||
"""插件来源身份读取与存量迁移使用的同步窄端口。"""
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""仅供存量迁移首次创建或按 revision 更新身份。"""
|
||||
|
||||
def bind_online(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""把存量未绑定身份按 revision 绑定到在线来源。"""
|
||||
|
||||
|
||||
class PluginPersistenceService:
|
||||
"""通过有界数据库 worker 暴露插件专用异步持久化能力。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
executor: AsyncDatabaseExecutor,
|
||||
identities: PluginIdentityPersistence,
|
||||
installations: PluginInstallationStore,
|
||||
) -> None:
|
||||
"""保存身份、安装事务和唯一同步数据库执行边界。"""
|
||||
self.__executor = executor
|
||||
self.__identities = identities
|
||||
self.__installations = installations
|
||||
|
||||
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""在数据库 worker 中读取插件来源身份。"""
|
||||
return await self.__executor.run(partial(self.__identities.get, plugin_id))
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""在数据库 worker 中提交存量身份迁移。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__identities.compare_and_set,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
async def bind_online_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在数据库 worker 中绑定存量身份的可信在线来源。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__identities.bind_online,
|
||||
identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
async def create_installation(
|
||||
self,
|
||||
record: PluginInstallationRecord,
|
||||
) -> PluginInstallationRecord:
|
||||
"""创建 PREPARED journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__installations.create, record)
|
||||
)
|
||||
|
||||
async def list_installations(self) -> list[PluginInstallationRecord]:
|
||||
"""列出全部待恢复或待清理的安装 journal。"""
|
||||
return await self.__executor.run(self.__installations.list)
|
||||
|
||||
async def get_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
) -> PluginInstallationRecord | None:
|
||||
"""读取一次提交结果确认所需的安装 journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__installations.get, transaction_id)
|
||||
)
|
||||
|
||||
async def set_installation_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""在 PREPARED journal 中登记最终数据库目标。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.set_target,
|
||||
transaction_id,
|
||||
membership_target=membership_target,
|
||||
identity_target=identity_target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
)
|
||||
|
||||
async def commit_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""原子提交 membership、身份和 COMMITTED phase。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.commit_target,
|
||||
transaction_id,
|
||||
identity_target=identity_target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase 删除已恢复或已收尾的 journal。"""
|
||||
return await self.__executor.run(
|
||||
partial(
|
||||
self.__installations.delete,
|
||||
transaction_id,
|
||||
expected_phase=expected_phase,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_PLUGIN_PERSISTENCE: list[PluginPersistenceService] = []
|
||||
|
||||
|
||||
def configure_plugin_persistence(service: PluginPersistenceService) -> None:
|
||||
"""由启动组合根登记当前 lifespan 的插件持久化服务。"""
|
||||
_PLUGIN_PERSISTENCE.clear()
|
||||
_PLUGIN_PERSISTENCE.append(service)
|
||||
|
||||
|
||||
def get_plugin_persistence() -> PluginPersistenceService:
|
||||
"""返回当前插件持久化服务,未装配时拒绝数据库操作。"""
|
||||
if not _PLUGIN_PERSISTENCE:
|
||||
raise RuntimeError("插件持久化服务尚未完成初始化")
|
||||
return _PLUGIN_PERSISTENCE[0]
|
||||
|
||||
|
||||
def reset_plugin_persistence() -> None:
|
||||
"""清除当前 lifespan 的插件持久化服务。"""
|
||||
_PLUGIN_PERSISTENCE.clear()
|
||||
@@ -7,6 +7,9 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
BindLocalPluginIdentityCommand,
|
||||
BindOnlinePluginIdentityCommand,
|
||||
ChangePluginIdentitySourceCommand,
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
@@ -143,3 +146,51 @@ class TransactionalPluginIdentityStore:
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def change_source(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在独占事务内提交明确的在线来源转换。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return ChangePluginIdentitySourceCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def bind_local(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在独占事务内提交 legacy_unbound 到 local_only 的转换。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return BindLocalPluginIdentityCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def bind_online(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在独占事务内提交未绑定身份的首次在线来源绑定。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return BindOnlinePluginIdentityCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""插件安装事务 Application Port 的同步 SQLAlchemy 实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationConflictError,
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
PluginInstallationStore,
|
||||
)
|
||||
from app.db.models.pluginidentity import PluginIdentity as IdentityModel
|
||||
from app.db.models.plugininstallation import PluginInstallation
|
||||
|
||||
_INSTALLED_PLUGINS_KEY = "UserInstalledPlugins"
|
||||
AtomicMembershipUpdater = Callable[
|
||||
[
|
||||
str,
|
||||
Callable[
|
||||
[Session, object],
|
||||
tuple[PluginInstallationRecord, object],
|
||||
],
|
||||
],
|
||||
PluginInstallationRecord,
|
||||
]
|
||||
|
||||
|
||||
def _identity_from_model(model: IdentityModel) -> PluginIdentity:
|
||||
"""把同一 Session 读出的身份模型还原为应用记录。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=model.plugin_id,
|
||||
normalized_plugin_id=model.normalized_plugin_id,
|
||||
trusted_source_type=TrustedPluginSourceType(model.trusted_source_type),
|
||||
trusted_source_key=model.trusted_source_key,
|
||||
binding_basis=PluginBindingBasis(model.binding_basis),
|
||||
payload_source_type=PluginPayloadSourceType(model.payload_source_type),
|
||||
payload_source_key=model.payload_source_key,
|
||||
declared_version=model.declared_version,
|
||||
package_generation=model.package_generation,
|
||||
system_version=model.system_version,
|
||||
supports_v3=model.supports_v3,
|
||||
supports_v3t=model.supports_v3t,
|
||||
payload_receipt=model.payload_receipt,
|
||||
revision=model.revision,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
updated_at=datetime.fromisoformat(model.updated_at),
|
||||
bound_at=(
|
||||
datetime.fromisoformat(model.bound_at)
|
||||
if model.bound_at
|
||||
else None
|
||||
),
|
||||
payload_applied_at=(
|
||||
datetime.fromisoformat(model.payload_applied_at)
|
||||
if model.payload_applied_at
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _identity_model_values(identity: PluginIdentity) -> dict[str, object]:
|
||||
"""把应用身份映射为不含自增主键的模型列值。"""
|
||||
return {
|
||||
"plugin_id": identity.plugin_id,
|
||||
"normalized_plugin_id": identity.normalized_plugin_id,
|
||||
"trusted_source_type": identity.trusted_source_type.value,
|
||||
"trusted_source_key": identity.trusted_source_key,
|
||||
"binding_basis": identity.binding_basis.value,
|
||||
"payload_source_type": identity.payload_source_type.value,
|
||||
"payload_source_key": identity.payload_source_key,
|
||||
"declared_version": identity.declared_version,
|
||||
"package_generation": identity.package_generation,
|
||||
"system_version": identity.system_version,
|
||||
"supports_v3": identity.supports_v3,
|
||||
"supports_v3t": identity.supports_v3t,
|
||||
"payload_receipt": identity.payload_receipt,
|
||||
"revision": identity.revision,
|
||||
"created_at": identity.created_at.isoformat(),
|
||||
"updated_at": identity.updated_at.isoformat(),
|
||||
"bound_at": identity.bound_at.isoformat() if identity.bound_at else None,
|
||||
"payload_applied_at": (
|
||||
identity.payload_applied_at.isoformat()
|
||||
if identity.payload_applied_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TransactionalPluginInstallationStore(PluginInstallationStore):
|
||||
"""以单张事务表协调单插件 membership、来源身份和 phase。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
update_membership_atomically: AtomicMembershipUpdater,
|
||||
) -> None:
|
||||
"""保存事务会话工厂和配置 membership 的窄原子写入口。"""
|
||||
self._session_factory = session_factory
|
||||
self.__update_membership_atomically = update_membership_atomically
|
||||
|
||||
def __session(self) -> Session:
|
||||
"""创建不会在提交后过期状态的短生命周期 Session。"""
|
||||
session = self._session_factory()
|
||||
session.expire_on_commit = False
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def __now() -> str:
|
||||
"""生成带时区的持久化更新时间。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def __phase(value: PluginInstallationPhase | str) -> PluginInstallationPhase:
|
||||
"""把调用方 phase 转为受限枚举。"""
|
||||
try:
|
||||
return (
|
||||
value
|
||||
if isinstance(value, PluginInstallationPhase)
|
||||
else PluginInstallationPhase(value)
|
||||
)
|
||||
except ValueError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"未知插件安装 phase: {value}"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def __to_record(model: PluginInstallation) -> PluginInstallationRecord:
|
||||
"""把 ORM 行还原为经过应用层校验的事务记录。"""
|
||||
try:
|
||||
return PluginInstallationRecord(
|
||||
transaction_id=model.transaction_id,
|
||||
plugin_id=model.plugin_id,
|
||||
phase=PluginInstallationPhase(model.phase),
|
||||
membership_before=model.membership_before,
|
||||
membership_target=model.membership_target,
|
||||
identity_before_revision=model.identity_before_revision,
|
||||
identity_target_revision=model.identity_target_revision,
|
||||
package_existed=model.package_existed,
|
||||
persistent_backup_existed=model.persistent_backup_existed,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
updated_at=datetime.fromisoformat(model.updated_at),
|
||||
schema_version=model.schema_version,
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件安装事务 {model.transaction_id} 的持久化状态无效"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def __identity_query(session: Session, plugin_id: str) -> IdentityModel | None:
|
||||
"""读取并锁定指定插件的身份行。"""
|
||||
return session.execute(
|
||||
select(IdentityModel)
|
||||
.where(IdentityModel.normalized_plugin_id == plugin_id.lower())
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def __membership_state(current: object, plugin_id: str) -> bool:
|
||||
"""只读取目标插件 membership,不把其他插件写入事务快照。"""
|
||||
if current is None:
|
||||
return False
|
||||
if not isinstance(current, list) or any(
|
||||
not isinstance(item, str) for item in current
|
||||
):
|
||||
raise PluginInstallationConflictError(
|
||||
"UserInstalledPlugins 当前值不是 JSON 字符串数组"
|
||||
)
|
||||
normalized_id = plugin_id.lower()
|
||||
return any(item.lower() == normalized_id for item in current)
|
||||
|
||||
@staticmethod
|
||||
def __write_membership(
|
||||
current: object,
|
||||
plugin_id: str,
|
||||
target: bool,
|
||||
) -> list[str]:
|
||||
"""在配置写锁内只增删目标插件,保留其他插件并发变更。"""
|
||||
if current is None:
|
||||
values: list[str] = []
|
||||
elif isinstance(current, list) and all(
|
||||
isinstance(item, str) for item in current
|
||||
):
|
||||
values = list(current)
|
||||
else:
|
||||
raise PluginInstallationConflictError(
|
||||
"UserInstalledPlugins 当前值不是 JSON 字符串数组"
|
||||
)
|
||||
|
||||
normalized_id = plugin_id.lower()
|
||||
values = [item for item in values if item.lower() != normalized_id]
|
||||
if target:
|
||||
values.append(plugin_id)
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def __identity_revision(
|
||||
cls,
|
||||
session: Session,
|
||||
plugin_id: str,
|
||||
) -> int | None:
|
||||
"""读取锁定身份行的 revision;缺行表示 CAS 的 null。"""
|
||||
identity = cls.__identity_query(session, plugin_id)
|
||||
return identity.revision if identity is not None else None
|
||||
|
||||
@classmethod
|
||||
def __write_identity(
|
||||
cls,
|
||||
session: Session,
|
||||
plugin_id: str,
|
||||
identity: PluginIdentity | None,
|
||||
) -> None:
|
||||
"""在调用方事务中写入或删除目标插件身份。"""
|
||||
current = cls.__identity_query(session, plugin_id)
|
||||
if identity is None:
|
||||
if current is not None:
|
||||
session.delete(current)
|
||||
return
|
||||
if identity.plugin_id != plugin_id:
|
||||
raise PluginInstallationConflictError(
|
||||
"PluginIdentity target 与事务 plugin_id 不一致"
|
||||
)
|
||||
values = _identity_model_values(identity)
|
||||
if current is None:
|
||||
session.add(IdentityModel(**values))
|
||||
else:
|
||||
for key, value in values.items():
|
||||
setattr(current, key, value)
|
||||
|
||||
@staticmethod
|
||||
def __assert_target_identity(
|
||||
record: PluginInstallationRecord,
|
||||
identity: PluginIdentity,
|
||||
) -> None:
|
||||
"""确认目标身份属于当前插件且 revision 只前进一步。"""
|
||||
if identity.plugin_id != record.plugin_id:
|
||||
raise PluginInstallationConflictError(
|
||||
"PluginIdentity target 与事务 plugin_id 不一致"
|
||||
)
|
||||
expected_revision = (record.identity_before_revision or 0) + 1
|
||||
if identity.revision != expected_revision:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {record.transaction_id} 的 target identity revision "
|
||||
f"必须为 {expected_revision}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __require_row(session: Session, transaction_id: str) -> PluginInstallation:
|
||||
"""读取并锁定事务行,缺失时拒绝继续写入。"""
|
||||
row = session.execute(
|
||||
select(PluginInstallation)
|
||||
.where(PluginInstallation.transaction_id == transaction_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件安装事务不存在: {transaction_id}"
|
||||
)
|
||||
return cast(PluginInstallation, row)
|
||||
|
||||
@classmethod
|
||||
def __check_phase(
|
||||
cls,
|
||||
row: PluginInstallation,
|
||||
expected_phase: PluginInstallationPhase | str,
|
||||
) -> PluginInstallationPhase:
|
||||
"""执行写操作共用的 phase CAS。"""
|
||||
expected = cls.__phase(expected_phase)
|
||||
try:
|
||||
actual = PluginInstallationPhase(row.phase)
|
||||
except ValueError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {row.transaction_id} 的 phase 无效: {row.phase}"
|
||||
) from error
|
||||
if actual is not expected:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {row.transaction_id} phase 已变化: "
|
||||
f"expected={expected.value}, actual={actual.value}"
|
||||
)
|
||||
return actual
|
||||
|
||||
@classmethod
|
||||
def __assert_before_state(
|
||||
cls,
|
||||
record: PluginInstallationRecord,
|
||||
session: Session,
|
||||
current_membership: object,
|
||||
) -> None:
|
||||
"""确认目标插件仍处于事务创建时的 before 状态。"""
|
||||
membership = cls.__membership_state(current_membership, record.plugin_id)
|
||||
revision = cls.__identity_revision(session, record.plugin_id)
|
||||
if membership != record.membership_before:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {record.transaction_id} 的插件 membership 发生漂移"
|
||||
)
|
||||
if revision != record.identity_before_revision:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {record.transaction_id} 的插件身份 revision 发生漂移"
|
||||
)
|
||||
|
||||
def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord:
|
||||
"""原子预留单插件 journal 槽位并立即 flush 唯一键竞争。"""
|
||||
def reserve(
|
||||
session: Session,
|
||||
current_membership: object,
|
||||
) -> tuple[PluginInstallationRecord, object]:
|
||||
"""在配置写事务内阻断同一物理插件的未收尾 journal。"""
|
||||
existing = session.execute(
|
||||
select(PluginInstallation)
|
||||
.where(
|
||||
func.lower(PluginInstallation.plugin_id)
|
||||
== record.plugin_id.lower()
|
||||
)
|
||||
.with_for_update()
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件 {record.plugin_id} 存在未收尾安装事务: "
|
||||
f"{existing.transaction_id} ({existing.phase})"
|
||||
)
|
||||
session.add(
|
||||
PluginInstallation(
|
||||
transaction_id=record.transaction_id,
|
||||
plugin_id=record.plugin_id,
|
||||
phase=record.phase.value,
|
||||
membership_before=record.membership_before,
|
||||
membership_target=record.membership_target,
|
||||
identity_before_revision=record.identity_before_revision,
|
||||
identity_target_revision=record.identity_target_revision,
|
||||
package_existed=record.package_existed,
|
||||
persistent_backup_existed=record.persistent_backup_existed,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
schema_version=record.schema_version,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return record, current_membership
|
||||
|
||||
try:
|
||||
return cast(
|
||||
PluginInstallationRecord,
|
||||
self.__update_membership_atomically(
|
||||
_INSTALLED_PLUGINS_KEY,
|
||||
reserve,
|
||||
),
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件安装事务创建发生并发竞争: {record.transaction_id}"
|
||||
) from error
|
||||
|
||||
def get(self, transaction_id: str) -> PluginInstallationRecord | None:
|
||||
"""按事务 ID 读取记录。"""
|
||||
session = self.__session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(PluginInstallation).where(
|
||||
PluginInstallation.transaction_id == transaction_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self.__to_record(row) if row else None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
) -> list[PluginInstallationRecord]:
|
||||
"""按创建时间稳定列出事务记录。"""
|
||||
session = self.__session()
|
||||
try:
|
||||
statement = select(PluginInstallation).order_by(
|
||||
PluginInstallation.created_at,
|
||||
PluginInstallation.transaction_id,
|
||||
)
|
||||
if plugin_id is not None:
|
||||
statement = statement.where(PluginInstallation.plugin_id == plugin_id)
|
||||
return [
|
||||
self.__to_record(row)
|
||||
for row in session.execute(statement).scalars()
|
||||
]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""按 phase CAS 登记目标 membership 和身份 revision,不写业务状态。"""
|
||||
if not isinstance(membership_target, bool):
|
||||
raise PluginInstallationConflictError("membership_target 必须是布尔值")
|
||||
session = self.__session()
|
||||
try:
|
||||
with session.begin():
|
||||
row = self.__require_row(session, transaction_id)
|
||||
self.__check_phase(row, expected_phase)
|
||||
record = self.__to_record(row)
|
||||
if identity_target is not None:
|
||||
self.__assert_target_identity(record, identity_target)
|
||||
row.membership_target = membership_target
|
||||
row.identity_target_revision = (
|
||||
identity_target.revision if identity_target is not None else None
|
||||
)
|
||||
row.updated_at = self.__now()
|
||||
session.flush()
|
||||
return self.__to_record(row)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def commit_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""原子提交目标 membership、身份 CAS 和 COMMITTED phase。"""
|
||||
def commit(
|
||||
session: Session,
|
||||
current_membership: object,
|
||||
) -> tuple[PluginInstallationRecord, list[str]]:
|
||||
"""在配置行锁持有期间完成事务行、身份和 membership 写入。"""
|
||||
row = self.__require_row(session, transaction_id)
|
||||
self.__check_phase(row, expected_phase)
|
||||
record = self.__to_record(row)
|
||||
if record.membership_target is None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {transaction_id} 尚未设置 membership target"
|
||||
)
|
||||
if identity_target is not None:
|
||||
self.__assert_target_identity(record, identity_target)
|
||||
if identity_target.revision != record.identity_target_revision:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {transaction_id} 的 target identity revision 不匹配"
|
||||
)
|
||||
elif record.identity_target_revision is not None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {transaction_id} 缺少 target identity"
|
||||
)
|
||||
|
||||
self.__assert_before_state(record, session, current_membership)
|
||||
updated_membership = self.__write_membership(
|
||||
current_membership,
|
||||
record.plugin_id,
|
||||
record.membership_target,
|
||||
)
|
||||
self.__write_identity(session, record.plugin_id, identity_target)
|
||||
row.phase = PluginInstallationPhase.COMMITTED.value
|
||||
row.updated_at = self.__now()
|
||||
session.flush()
|
||||
return self.__to_record(row), updated_membership
|
||||
|
||||
try:
|
||||
return cast(
|
||||
PluginInstallationRecord,
|
||||
self.__update_membership_atomically(
|
||||
_INSTALLED_PLUGINS_KEY,
|
||||
commit,
|
||||
),
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件 {transaction_id} 的身份提交发生唯一键竞争"
|
||||
) from error
|
||||
|
||||
def delete(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase CAS 删除事务记录;缺失记录按幂等删除处理。"""
|
||||
session = self.__session()
|
||||
try:
|
||||
with session.begin():
|
||||
row = session.execute(
|
||||
select(PluginInstallation)
|
||||
.where(PluginInstallation.transaction_id == transaction_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
self.__check_phase(row, expected_phase)
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return True
|
||||
finally:
|
||||
session.close()
|
||||
@@ -18,6 +18,10 @@ _MODEL_EXPORTS = {
|
||||
"OutboxMessage": ("app.db.models.outbox", "OutboxMessage"),
|
||||
"PassKey": ("app.db.models.passkey", "PassKey"),
|
||||
"PluginData": ("app.db.models.plugindata", "PluginData"),
|
||||
"PluginInstallation": (
|
||||
"app.db.models.plugininstallation",
|
||||
"PluginInstallation",
|
||||
),
|
||||
"PluginIdentity": (
|
||||
"app.db.models.pluginidentity",
|
||||
"PluginIdentity",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""插件安装事务的单表持久化模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class PluginInstallation(Base):
|
||||
"""保存单插件 membership、身份 CAS revision 和持久备份状态。"""
|
||||
|
||||
id = get_id_column()
|
||||
transaction_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
plugin_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
phase: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
membership_before: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
membership_target: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
identity_before_revision: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
identity_target_revision: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
package_existed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
persistent_backup_existed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"transaction_id",
|
||||
name="uq_plugininstallation_transaction_id",
|
||||
),
|
||||
Index("ix_plugininstallation_plugin_id", "plugin_id"),
|
||||
Index("ix_plugininstallation_phase", "phase"),
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
import copy
|
||||
import threading
|
||||
from typing import Any, Optional, Union
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional, TypeVar, Union
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
@@ -9,6 +11,8 @@ from app.db.models.systemconfig import SystemConfig
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
"""
|
||||
@@ -80,6 +84,37 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
self._publish_value(key, value)
|
||||
return result
|
||||
|
||||
def update_atomically(
|
||||
self,
|
||||
key: Union[str, SystemConfigKey],
|
||||
mutation: Callable[[Session, Any], tuple[T, Any]],
|
||||
) -> T:
|
||||
"""在配置写锁内提交关联记录,并在事务成功后发布最终配置值。"""
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
self._require_loaded()
|
||||
with self._write_lock:
|
||||
|
||||
def write(db: Session) -> tuple[T, Any]:
|
||||
"""锁定配置行,把关联写入与最终配置值放入同一事务。"""
|
||||
conf = db.execute(
|
||||
select(SystemConfig)
|
||||
.where(SystemConfig.key == key)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
current = copy.deepcopy(conf.value if conf else None)
|
||||
result, value = mutation(db, current)
|
||||
committed_value = copy.deepcopy(value)
|
||||
if conf:
|
||||
conf.value = committed_value
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=committed_value))
|
||||
return result, committed_value
|
||||
|
||||
result, committed_value = self._execute_sync_write(write)
|
||||
self._publish_value(key, committed_value)
|
||||
return result
|
||||
|
||||
def get(self, key: Optional[Union[str, SystemConfigKey]] = None) -> Any:
|
||||
"""
|
||||
获取系统设置
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"文件列表为空": "File list is empty",
|
||||
"requirements.txt 文件下载失败": "Failed to download requirements.txt",
|
||||
"插件在仓库中不存在或返回数据格式不正确": "The plugin does not exist in the repository or the returned data format is invalid",
|
||||
"插件来源身份不存在": "Plugin source identity does not exist",
|
||||
"插件数据解析失败": "Failed to parse plugin data",
|
||||
"没有传入需要安装的依赖项": "No dependencies to install were provided",
|
||||
"资产缺少ID信息": "Asset is missing ID information",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"文件列表为空": "檔案清單為空",
|
||||
"requirements.txt 文件下载失败": "requirements.txt 檔案下載失敗",
|
||||
"插件在仓库中不存在或返回数据格式不正确": "插件在倉庫中不存在或返回資料格式不正確",
|
||||
"插件来源身份不存在": "插件來源身分不存在",
|
||||
"插件数据解析失败": "插件資料解析失敗",
|
||||
"没有传入需要安装的依赖项": "未傳入需要安裝的依賴項",
|
||||
"资产缺少ID信息": "資產缺少 ID 資訊",
|
||||
|
||||
@@ -153,7 +153,7 @@ class PluginCatalogFacade:
|
||||
plugin_info=info,
|
||||
market=self._system().local_repo_url(
|
||||
plugin_id,
|
||||
info.get("repo_path"),
|
||||
None,
|
||||
package_version,
|
||||
),
|
||||
installed_apps=installed,
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||||
@@ -22,8 +21,7 @@ class PluginSyncService:
|
||||
local_plugins: Callable[[], list[Any]],
|
||||
merge_plugins: Callable[[list[Any], list[Any], list[Any]], list[Any]],
|
||||
plugin_exists: Callable[[str, Optional[str]], bool],
|
||||
install: Callable[[str, Optional[str], bool], tuple[bool, str]],
|
||||
report: Callable[..., Any],
|
||||
install: Callable[[str, Optional[str], bool, object | None], tuple[bool, str]],
|
||||
log: Any,
|
||||
) -> None:
|
||||
"""保存目录读取、包安装和持久化报告端口。"""
|
||||
@@ -34,24 +32,38 @@ class PluginSyncService:
|
||||
self._merge_plugins = merge_plugins
|
||||
self._plugin_exists = plugin_exists
|
||||
self._install = install
|
||||
self._report = report
|
||||
self._logger = log
|
||||
|
||||
def sync(self) -> list[str]:
|
||||
"""并发安装本地缺失或需要更新的已安装插件。"""
|
||||
def sync(
|
||||
self,
|
||||
startup_token: object | None = None,
|
||||
*,
|
||||
online_restore_plugins: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""并发安装本地缺失、需要更新或应恢复在线载荷的插件。"""
|
||||
if self._frozen():
|
||||
return []
|
||||
|
||||
installed = self._installed_plugins()
|
||||
online = self._online_plugins()
|
||||
local = self._local_plugins()
|
||||
local_plugin_ids = {plugin.id.lower() for plugin in local}
|
||||
restore_plugin_ids = {
|
||||
plugin_id.lower()
|
||||
for plugin_id in (online_restore_plugins or set())
|
||||
} - local_plugin_ids
|
||||
candidates = self._merge_plugins(online + local, [], []) if online or local else []
|
||||
targets = [
|
||||
plugin
|
||||
for plugin in candidates
|
||||
if plugin.id in installed
|
||||
and plugin.system_version_compatible is not False
|
||||
and not self._plugin_exists(plugin.id, plugin.plugin_version)
|
||||
and (
|
||||
plugin.id.lower() in restore_plugin_ids
|
||||
or (
|
||||
plugin.system_version_compatible is not False
|
||||
and not self._plugin_exists(plugin.id, plugin.plugin_version)
|
||||
)
|
||||
)
|
||||
]
|
||||
if not targets:
|
||||
return []
|
||||
@@ -63,10 +75,14 @@ class PluginSyncService:
|
||||
def install_one(plugin: Any) -> None:
|
||||
"""安装一个插件并记录结果。"""
|
||||
started = time.time()
|
||||
state, message = self._install(plugin.id, plugin.repo_url, False)
|
||||
state, message = self._install(
|
||||
plugin.id,
|
||||
None,
|
||||
False,
|
||||
startup_token,
|
||||
)
|
||||
elapsed = time.time() - started
|
||||
if state:
|
||||
self._report(plugin_id=plugin.id, repo_url=plugin.repo_url)
|
||||
self._logger.info(
|
||||
f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version},"
|
||||
f"耗时:{elapsed:.2f} 秒"
|
||||
@@ -128,12 +144,24 @@ class LocalPluginSyncService:
|
||||
f"{candidate.get('skip_reason')}"
|
||||
)
|
||||
return False
|
||||
source_dir = Path(candidate.get("path"))
|
||||
repo_url = candidate.get("repo_url")
|
||||
if not isinstance(repo_url, str) or not repo_url.startswith("local://"):
|
||||
self._logger.error(f"本地插件 {plugin_id} 缺少可验证的本地来源标识")
|
||||
return False
|
||||
try:
|
||||
if not self._system().package.sync_local(plugin_id, source_dir):
|
||||
state, message = self._system().install_plugin(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=candidate.get("package_version") or None,
|
||||
force=True,
|
||||
local_sync=True,
|
||||
explicit_source=True,
|
||||
)
|
||||
if not state:
|
||||
self._logger.error(f"同步本地插件 {plugin_id} 失败:{message}")
|
||||
return False
|
||||
self._recent_sync[plugin_id] = time.time()
|
||||
self._logger.info(f"已同步本地插件 {plugin_id}:{source_dir}")
|
||||
self._logger.info(f"已同步本地插件 {plugin_id}")
|
||||
return True
|
||||
except Exception as error:
|
||||
self._logger.error(f"同步本地插件 {plugin_id} 失败:{error}")
|
||||
|
||||
@@ -19,14 +19,16 @@ class PluginSystemServices:
|
||||
dependency_manifest_status: Callable[[Path], Optional[bool]],
|
||||
compatible_flags: Callable[[Optional[str]], list[str]],
|
||||
frozen: Callable[[], bool],
|
||||
install: Callable[..., tuple[bool, str]],
|
||||
) -> None:
|
||||
"""记录市场、包、依赖和代际兼容计算端口。"""
|
||||
"""记录市场、包、安装 Gateway、依赖和代际兼容计算端口。"""
|
||||
self.market = market
|
||||
self.package = package
|
||||
self.dependency = dependency
|
||||
self.dependency_manifest_status = dependency_manifest_status
|
||||
self.compatible_flags = compatible_flags
|
||||
self.frozen = frozen
|
||||
self.install = install
|
||||
|
||||
def local_repo_paths(self) -> list[Path]:
|
||||
"""返回可监测的本地插件仓库路径。"""
|
||||
@@ -65,6 +67,30 @@ class PluginSystemServices:
|
||||
"""判断当前宿主是否为不可写的冻结运行模式。"""
|
||||
return self.frozen()
|
||||
|
||||
def install_plugin(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: str | None,
|
||||
package_version: str | None = None,
|
||||
release_version: str | None = None,
|
||||
force: bool = False,
|
||||
local_sync: bool = False,
|
||||
explicit_source: bool = False,
|
||||
startup_token: object | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""从同步运行时线程进入宿主唯一安装 Gateway。"""
|
||||
return self.install(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
explicit_source=explicit_source,
|
||||
startup_token=startup_token,
|
||||
)
|
||||
|
||||
|
||||
_services: Optional[PluginSystemServices] = None
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ from app.schemas.types import EventType, SystemConfigKey
|
||||
LegacyDiagnosticsConfigurator = Callable[..., None]
|
||||
LegacyImportScanner = Callable[..., None]
|
||||
LegacyPluginImportPreparer = Callable[..., None]
|
||||
PluginInstallReporter = Callable[..., None]
|
||||
SiteAuthLevelProvider = Callable[[], int]
|
||||
PluginCatalogFactory = Callable[["PluginManager"], Any]
|
||||
PluginRouteRefresher = Callable[[str], None]
|
||||
@@ -122,7 +121,6 @@ _legacy_import_scanner: LegacyImportScanner = _ignore_legacy_diagnostics
|
||||
_legacy_plugin_import_preparer: LegacyPluginImportPreparer = (
|
||||
_ignore_plugin_resource_imports
|
||||
)
|
||||
_plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics
|
||||
_site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level
|
||||
_plugin_catalog_factory: PluginCatalogFactory = _unavailable_plugin_catalog_factory
|
||||
_plugin_route_refresher: PluginRouteRefresher = _unavailable_plugin_route_refresher
|
||||
@@ -147,12 +145,6 @@ def configure_plugin_resource_import_preparer(
|
||||
_legacy_plugin_import_preparer = preparer
|
||||
|
||||
|
||||
def configure_plugin_install_reporter(reporter: PluginInstallReporter) -> None:
|
||||
"""由启动组合根注入插件安装上报器,避免扩展层依赖远程服务。"""
|
||||
global _plugin_install_reporter
|
||||
_plugin_install_reporter = reporter
|
||||
|
||||
|
||||
def configure_site_auth_level_provider(provider: SiteAuthLevelProvider) -> None:
|
||||
"""由启动组合根注入站点认证等级,避免扩展运行时依赖应用服务。"""
|
||||
global _site_auth_level_provider
|
||||
@@ -317,12 +309,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
plugin_id,
|
||||
version,
|
||||
),
|
||||
install=lambda plugin_id, repo_url, force: get_plugin_system().package.install(
|
||||
install=lambda plugin_id, repo_url, force, startup_token: get_plugin_system().install_plugin(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
force_install=force,
|
||||
force=force,
|
||||
startup_token=startup_token,
|
||||
),
|
||||
report=lambda **kwargs: _plugin_install_reporter(**kwargs),
|
||||
log=logger,
|
||||
)
|
||||
self._plugin_clone = PluginCloneService(
|
||||
@@ -826,13 +818,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
log=logger,
|
||||
).clear_modules(plugin_id)
|
||||
|
||||
def sync(self) -> List[str]:
|
||||
def sync(
|
||||
self,
|
||||
startup_token: object | None = None,
|
||||
*,
|
||||
online_restore_plugins: set[str] | None = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
安装本地不存在或需要更新的插件
|
||||
"""
|
||||
|
||||
with self.mutation("同步插件包"):
|
||||
return self._plugin_sync.sync()
|
||||
return self._plugin_sync.sync(
|
||||
startup_token,
|
||||
online_restore_plugins=online_restore_plugins,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def install_plugin_missing_dependencies() -> List[str]:
|
||||
|
||||
@@ -292,6 +292,11 @@ SCHEMA_EXPORTS = {
|
||||
'PluginRuntimeStatus': ('app.schemas.plugin', 'PluginRuntimeStatus'),
|
||||
'PluginRuntimeSummary': ('app.schemas.plugin', 'PluginRuntimeSummary'),
|
||||
'PluginSidebarNavItem': ('app.schemas.plugin', 'PluginSidebarNavItem'),
|
||||
'PluginSourceCandidate': ('app.schemas.plugin', 'PluginSourceCandidate'),
|
||||
'PluginSourceChangeRequest': ('app.schemas.plugin', 'PluginSourceChangeRequest'),
|
||||
'PluginSourceIdentity': ('app.schemas.plugin', 'PluginSourceIdentity'),
|
||||
'PluginSourceInstallRequest': ('app.schemas.plugin', 'PluginSourceInstallRequest'),
|
||||
'PluginSourceOptions': ('app.schemas.plugin', 'PluginSourceOptions'),
|
||||
'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'),
|
||||
'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'),
|
||||
'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'),
|
||||
|
||||
@@ -128,6 +128,118 @@ class PluginCloneRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class PluginSourceIdentity(BaseModel): # type: ignore[misc]
|
||||
"""显式换源确认所需的插件来源身份投影。"""
|
||||
|
||||
plugin_id: str = Field(description="物理插件 ID")
|
||||
trusted_source_type: str = Field(description="当前可信在线来源类型")
|
||||
trusted_source_key: Optional[str] = Field(
|
||||
default=None,
|
||||
description="规范化的可信在线来源键;未绑定时为空",
|
||||
)
|
||||
binding_basis: str = Field(description="当前可信来源的建立依据")
|
||||
payload_source_type: str = Field(description="最近一次已提交载荷的来源类型")
|
||||
payload_source_key: Optional[str] = Field(
|
||||
default=None,
|
||||
description="最近一次在线载荷的来源键;本地或未知载荷为空",
|
||||
)
|
||||
revision: int = Field(ge=1, description="显式换源使用的身份 CAS revision")
|
||||
|
||||
|
||||
class PluginSourceCandidate(BaseModel): # type: ignore[misc]
|
||||
"""一个可供管理员识别的脱敏插件来源候选。"""
|
||||
|
||||
source_type: Literal["official", "third_party", "local"] = Field(
|
||||
description="来源类型;本地候选不公开路径"
|
||||
)
|
||||
source_key: Optional[str] = Field(
|
||||
default=None,
|
||||
description="规范化在线来源键;本地候选为空",
|
||||
)
|
||||
repo_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="可明确选择的在线仓库地址;本地候选为空",
|
||||
)
|
||||
package_generation: Literal["v1", "v2", "v3"] = Field(
|
||||
description="当前运行时会采用的插件包代际"
|
||||
)
|
||||
plugin_version: Optional[str] = Field(
|
||||
default=None,
|
||||
description="该来源当前可安装的插件版本",
|
||||
)
|
||||
|
||||
|
||||
class PluginSourceOptions(BaseModel): # type: ignore[misc]
|
||||
"""来源选择界面所需的当前身份、候选和准入状态。"""
|
||||
|
||||
plugin_id: str = Field(description="物理插件 ID")
|
||||
inventory_complete: bool = Field(
|
||||
description="本轮配置市场是否全部得到确定读取结果"
|
||||
)
|
||||
selection_status: Literal[
|
||||
"selected", "unavailable", "conflict", "incomplete"
|
||||
] = Field(description="未指定新来源时的当前准入状态")
|
||||
selection_reason: str = Field(description="当前准入状态的人类可读原因")
|
||||
identity: Optional[PluginSourceIdentity] = Field(
|
||||
default=None,
|
||||
description="已安装插件的来源身份;未建立身份时为空",
|
||||
)
|
||||
candidates: List[PluginSourceCandidate] = Field(
|
||||
default_factory=list,
|
||||
description="按来源归并后的在线候选及可选本地候选",
|
||||
)
|
||||
|
||||
|
||||
class PluginSourceInstallRequest(BaseModel): # type: ignore[misc]
|
||||
"""管理员为未绑定插件明确选择初始在线来源的请求参数。"""
|
||||
|
||||
repo_url: str = Field(min_length=1, description="明确选择的目标插件仓库地址")
|
||||
release_version: Optional[str] = Field(
|
||||
default=None,
|
||||
description="指定安装的 Release 资产版本;为空时使用当前索引版本",
|
||||
)
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description="是否强制重新下载并安装所选来源载荷",
|
||||
)
|
||||
|
||||
@field_validator("repo_url") # type: ignore[misc]
|
||||
@classmethod
|
||||
def normalize_repo_url(cls, value: str) -> str:
|
||||
"""拒绝只含空白或本地路径标识的来源选择。"""
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("显式安装必须指定目标在线来源")
|
||||
if normalized.startswith("local://"):
|
||||
raise ValueError("显式来源安装只接受在线插件仓库")
|
||||
return normalized
|
||||
|
||||
|
||||
class PluginSourceChangeRequest(BaseModel): # type: ignore[misc]
|
||||
"""管理员显式切换插件在线来源的请求参数。"""
|
||||
|
||||
repo_url: str = Field(min_length=1, description="明确选择的目标插件仓库地址")
|
||||
expected_revision: int = Field(
|
||||
ge=1,
|
||||
description="提交换源时必须匹配的当前身份 revision",
|
||||
)
|
||||
release_version: Optional[str] = Field(
|
||||
default=None,
|
||||
description="指定安装的 Release 资产版本;为空时使用当前索引版本",
|
||||
)
|
||||
|
||||
@field_validator("repo_url") # type: ignore[misc]
|
||||
@classmethod
|
||||
def normalize_repo_url(cls, value: str) -> str:
|
||||
"""拒绝只含空白或本地路径标识的换源目标。"""
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("显式换源必须指定目标在线来源")
|
||||
if normalized.startswith("local://"):
|
||||
raise ValueError("显式换源只接受在线插件仓库")
|
||||
return normalized
|
||||
|
||||
|
||||
class PluginDashboard(Plugin):
|
||||
"""
|
||||
插件仪表盘
|
||||
|
||||
@@ -4,8 +4,14 @@ import sys
|
||||
from typing import Callable
|
||||
|
||||
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
|
||||
from app.application.plugin.transaction import (
|
||||
PluginPersistenceService,
|
||||
configure_plugin_persistence,
|
||||
)
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore
|
||||
from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore
|
||||
|
||||
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
||||
try:
|
||||
@@ -198,7 +204,7 @@ async def stop_database_worker() -> None:
|
||||
|
||||
async def _initialize_configuration_services(
|
||||
database_worker: DatabaseWorker,
|
||||
) -> None:
|
||||
) -> SystemConfigOper:
|
||||
"""加载完整配置快照后发布系统与用户配置服务。"""
|
||||
system_config = SystemConfigOper()
|
||||
user_config = UserConfigOper()
|
||||
@@ -216,6 +222,7 @@ async def _initialize_configuration_services(
|
||||
async_executor=database_worker,
|
||||
)
|
||||
)
|
||||
return system_config
|
||||
|
||||
|
||||
def _build_runtime_settings_service() -> RuntimeSettingsService:
|
||||
@@ -699,13 +706,23 @@ async def init_modules() -> HostRuntime:
|
||||
await database_worker.start()
|
||||
_database_worker = database_worker
|
||||
try:
|
||||
await _initialize_configuration_services(database_worker)
|
||||
system_config = await _initialize_configuration_services(database_worker)
|
||||
except BaseException:
|
||||
try:
|
||||
await stop_database_worker()
|
||||
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||
raise
|
||||
configure_plugin_persistence(
|
||||
PluginPersistenceService(
|
||||
executor=database_worker,
|
||||
identities=TransactionalPluginIdentityStore(SessionFactory),
|
||||
installations=TransactionalPluginInstallationStore(
|
||||
SessionFactory,
|
||||
system_config.update_atomically,
|
||||
),
|
||||
)
|
||||
)
|
||||
# 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。
|
||||
api_data = ApiDataPorts(
|
||||
sync_session=get_db,
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.application.commands import init_commands
|
||||
from app.application.plugin.gateway import (
|
||||
PluginInstallGateway,
|
||||
configure_plugin_install_service,
|
||||
)
|
||||
from app.application.plugin.identity import (
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
normalize_physical_plugin_id,
|
||||
)
|
||||
from app.application.plugin.identity_migration import (
|
||||
PluginIdentityMigrationService,
|
||||
configure_plugin_identity_migration,
|
||||
get_plugin_identity_migration,
|
||||
)
|
||||
from app.application.plugin.install import PluginInstallCommand
|
||||
from app.application.plugin.inventory import PluginCandidateInventoryReader
|
||||
from app.application.plugin.lifecycle import PluginStartupLease
|
||||
from app.application.plugin.recovery import (
|
||||
PluginInstallationRecoveryService,
|
||||
configure_plugin_installation_recovery,
|
||||
)
|
||||
from app.application.plugin.routes import register_plugin_api
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.application.plugin.transaction import (
|
||||
PluginPersistenceService,
|
||||
get_plugin_persistence,
|
||||
)
|
||||
from app.application.scheduling import update_plugin_job
|
||||
from app.runtime.compat.diagnostics import (
|
||||
configure_legacy_import_diagnostics,
|
||||
scan_plugin_legacy_imports,
|
||||
@@ -11,9 +42,12 @@ from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.adapters.external.market import (
|
||||
LOCAL_REPO_PREFIX,
|
||||
VERSION_BACKWARD_COMPATIBLE_FLAGS,
|
||||
PluginHelper,
|
||||
configure_installed_plugins_provider,
|
||||
configure_plugin_install_gateway,
|
||||
split_plugin_market_repo_urls,
|
||||
)
|
||||
from app.adapters.external.plugin.client import PluginMarketClient
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
@@ -42,7 +76,6 @@ from app.runtime.extensions.plugin.system import (
|
||||
from app.runtime.extensions.plugin_manager import (
|
||||
PluginManager,
|
||||
configure_plugin_catalog_factory,
|
||||
configure_plugin_install_reporter,
|
||||
configure_plugin_legacy_import_services,
|
||||
configure_plugin_resource_import_preparer,
|
||||
configure_plugin_route_refresher,
|
||||
@@ -85,12 +118,137 @@ def configure_plugin_services() -> None:
|
||||
"""把兼容诊断、远程上报和站点认证等级装配到插件管理器。"""
|
||||
plugin_helper = PluginHelper()
|
||||
market_client = PluginMarketClient(plugin_helper)
|
||||
package_manager = PluginPackageManager(plugin_helper)
|
||||
plugin_manager = get_plugin_manager()
|
||||
inventory_reader = PluginCandidateInventoryReader(
|
||||
market_loader=market_client.get_plugin_index_result,
|
||||
async_market_loader=market_client.async_get_plugin_index_result,
|
||||
local_candidate_loader=market_client.get_local_candidates,
|
||||
)
|
||||
persistence = get_plugin_persistence()
|
||||
|
||||
async def load_inventory(force: bool):
|
||||
"""读取本轮配置市场和本地仓库的完整候选事实。"""
|
||||
return await inventory_reader.async_load(
|
||||
split_plugin_market_repo_urls(settings.PLUGIN_MARKET),
|
||||
force=force,
|
||||
)
|
||||
|
||||
async def reload_plugin_tree(plugin_id: str) -> object:
|
||||
"""在线程池中重建源插件及其全部虚拟实例。"""
|
||||
return await run_in_threadpool_to_completion(
|
||||
plugin_manager.reload_plugin_tree,
|
||||
plugin_id,
|
||||
)
|
||||
|
||||
async def refresh_plugin_registrations(plugin_id: str) -> None:
|
||||
"""刷新源插件及其虚拟实例的调度、命令和路由注册。"""
|
||||
for target_id in plugin_manager.get_plugin_reload_targets(plugin_id):
|
||||
await run_in_threadpool_to_completion(
|
||||
_register_plugin_runtime,
|
||||
target_id,
|
||||
)
|
||||
|
||||
command = PluginInstallCommand(
|
||||
persistence=persistence,
|
||||
installed_plugins_reader=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
plugin_ids_provider=plugin_manager.get_plugin_ids,
|
||||
packages=package_manager,
|
||||
install_reporter=lambda plugin_id, repo_url: (
|
||||
MoviePilotServerHelper.async_install_plugin_reg(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
),
|
||||
target_reloader=reload_plugin_tree,
|
||||
rollback_reloader=reload_plugin_tree,
|
||||
registration_refresher=refresh_plugin_registrations,
|
||||
mutation=plugin_manager.mutation,
|
||||
package_write_guard=plugin_manager.suppress_plugin_monitor,
|
||||
clock=lambda: datetime.now(timezone.utc),
|
||||
transaction_id_factory=lambda: uuid.uuid4().hex,
|
||||
)
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=load_inventory,
|
||||
identity=persistence.get_identity,
|
||||
candidate_compatibility=lambda candidate: (
|
||||
plugin_helper.check_plugin_system_version(candidate.dto)
|
||||
),
|
||||
executor=command,
|
||||
clock=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
configure_plugin_install_service(gateway)
|
||||
configure_plugin_installation_recovery(
|
||||
PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=package_manager,
|
||||
)
|
||||
)
|
||||
configure_plugin_identity_migration(
|
||||
PluginIdentityMigrationService(
|
||||
persistence=persistence,
|
||||
inventory=load_inventory,
|
||||
installed_plugins=lambda: get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
is_virtual_instance=lambda plugin_id: (
|
||||
plugin_manager.get_plugin_instance(plugin_id) is not None
|
||||
),
|
||||
clock=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
def install_from_compat_helper(
|
||||
plugin_id: str,
|
||||
repo_url: str,
|
||||
package_version: str | None,
|
||||
release_version: str | None,
|
||||
force: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""保留本地来源定位;在线兼容参数不得升级为选源授权。"""
|
||||
local_sync = bool(repo_url and repo_url.startswith(LOCAL_REPO_PREFIX))
|
||||
return _run_plugin_install_sync(
|
||||
gateway,
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url if local_sync else "",
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
explicit_source=local_sync,
|
||||
)
|
||||
|
||||
async def async_install_from_compat_helper(
|
||||
plugin_id: str,
|
||||
repo_url: str,
|
||||
package_version: str | None,
|
||||
release_version: str | None,
|
||||
force: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""异步保留本地来源定位;在线兼容参数不得升级为选源授权。"""
|
||||
local_sync = bool(repo_url and repo_url.startswith(LOCAL_REPO_PREFIX))
|
||||
return await _run_plugin_install_async(
|
||||
gateway,
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url if local_sync else "",
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
explicit_source=local_sync,
|
||||
)
|
||||
|
||||
configure_plugin_install_gateway(
|
||||
install=install_from_compat_helper,
|
||||
async_install=async_install_from_compat_helper,
|
||||
)
|
||||
configure_plugin_legacy_import_services(
|
||||
diagnostics_configurator=configure_legacy_import_diagnostics,
|
||||
import_scanner=scan_plugin_legacy_imports,
|
||||
)
|
||||
configure_plugin_resource_import_preparer(_prepare_legacy_plugin_import)
|
||||
configure_plugin_install_reporter(MoviePilotServerHelper.install_plugin_reg)
|
||||
configure_site_auth_level_provider(lambda: SitesHelper().auth_level)
|
||||
configure_installed_plugins_provider(
|
||||
lambda: get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
@@ -99,7 +257,7 @@ def configure_plugin_services() -> None:
|
||||
configure_plugin_route_refresher(register_plugin_api)
|
||||
configure_plugin_system(PluginSystemServices(
|
||||
market=market_client,
|
||||
package=PluginPackageManager(plugin_helper),
|
||||
package=package_manager,
|
||||
dependency=PluginDependencyInstaller(
|
||||
plugin_helper,
|
||||
installed_plugins_provider=lambda: get_configured_system_config().get(
|
||||
@@ -113,6 +271,7 @@ def configure_plugin_services() -> None:
|
||||
if flag else []
|
||||
),
|
||||
frozen=SystemUtils.is_frozen,
|
||||
install=lambda **kwargs: _run_plugin_install_sync(gateway, **kwargs),
|
||||
))
|
||||
configure_plugin_storage(PluginStorage(
|
||||
read=lambda key: get_configured_system_config().get(key),
|
||||
@@ -123,6 +282,112 @@ def configure_plugin_services() -> None:
|
||||
))
|
||||
|
||||
|
||||
def _register_plugin_runtime(plugin_id: str) -> None:
|
||||
"""重建一个插件的定时任务、命令和动态路由注册。"""
|
||||
update_plugin_job(plugin_id)
|
||||
init_commands(plugin_id)
|
||||
register_plugin_api(plugin_id)
|
||||
|
||||
|
||||
async def _collect_online_restore_plugins(
|
||||
persistence: PluginPersistenceService,
|
||||
installed_plugins: list[str],
|
||||
) -> set[str]:
|
||||
"""找出当前载荷为本地且仍保留可信在线来源的物理插件。"""
|
||||
restore_plugins: set[str] = set()
|
||||
seen: set[str] = set()
|
||||
for plugin_id in installed_plugins:
|
||||
try:
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
except ValueError:
|
||||
continue
|
||||
if normalized_id in seen:
|
||||
continue
|
||||
seen.add(normalized_id)
|
||||
identity = await persistence.get_identity(normalized_id)
|
||||
if (
|
||||
identity is not None
|
||||
and identity.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
and identity.payload_source_type is PluginPayloadSourceType.LOCAL
|
||||
):
|
||||
restore_plugins.add(normalized_id)
|
||||
return restore_plugins
|
||||
|
||||
|
||||
async def _run_plugin_install_async(
|
||||
gateway: PluginInstallGateway,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: str,
|
||||
package_version: str | None,
|
||||
release_version: str | None,
|
||||
force: bool,
|
||||
local_sync: bool,
|
||||
explicit_source: bool,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""把公开异步兼容入口转为统一 Gateway 结果。"""
|
||||
try:
|
||||
result = await gateway.install(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url or None,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
explicit_source=explicit_source,
|
||||
startup_token=startup_token,
|
||||
local_sync=local_sync,
|
||||
)
|
||||
return result.success, result.message
|
||||
except Exception as error: # noqa: BLE001 - 公开兼容入口以结果表达失败
|
||||
logger.error("插件 %s 异步安装失败:%s", plugin_id, error)
|
||||
return False, str(error)
|
||||
|
||||
|
||||
def _run_plugin_install_sync(
|
||||
gateway: PluginInstallGateway,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: str,
|
||||
package_version: str | None,
|
||||
release_version: str | None,
|
||||
force: bool,
|
||||
local_sync: bool,
|
||||
explicit_source: bool,
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""从插件工作线程把同步兼容调用提交到宿主事件循环。"""
|
||||
try:
|
||||
loop = global_vars.loop
|
||||
except RuntimeError:
|
||||
return False, "插件安装服务当前不可用"
|
||||
try:
|
||||
current_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
current_loop = None
|
||||
if current_loop is loop:
|
||||
return False, "事件循环内请使用 PluginHelper.async_install()"
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_run_plugin_install_async(
|
||||
gateway,
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=local_sync,
|
||||
explicit_source=explicit_source,
|
||||
startup_token=startup_token,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
try:
|
||||
return future.result()
|
||||
except Exception as error: # noqa: BLE001 - 兼容入口以结果表达失败
|
||||
logger.error("插件 %s 同步安装失败:%s", plugin_id, error)
|
||||
return False, str(error)
|
||||
|
||||
|
||||
def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService:
|
||||
"""在组合根连接目录用例、市场客户端、持久化读取和插件 DTO 映射。"""
|
||||
client = PluginMarketClient()
|
||||
@@ -140,7 +405,9 @@ def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService:
|
||||
)
|
||||
|
||||
|
||||
async def sync_plugins() -> bool:
|
||||
async def sync_plugins(
|
||||
startup_token: PluginStartupLease | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
初始化安装插件,并动态注册后台任务及API
|
||||
"""
|
||||
@@ -150,8 +417,21 @@ async def sync_plugins() -> bool:
|
||||
plugin_manager = PluginManager()
|
||||
with plugin_manager.mutation("启动后同步插件"):
|
||||
configure_plugin_services()
|
||||
await get_plugin_identity_migration().migrate()
|
||||
installed_plugins = get_configured_system_config().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or []
|
||||
online_restore_plugins = await _collect_online_restore_plugins(
|
||||
get_plugin_persistence(),
|
||||
installed_plugins,
|
||||
)
|
||||
plugin_manager.set_plugin_settling(True)
|
||||
return await _sync_plugins_admitted(plugin_manager, loop)
|
||||
return await _sync_plugins_admitted(
|
||||
plugin_manager,
|
||||
loop,
|
||||
startup_token,
|
||||
online_restore_plugins,
|
||||
)
|
||||
except PluginMutationRejectedError as error:
|
||||
logger.warning(str(error))
|
||||
return False
|
||||
@@ -160,9 +440,21 @@ async def sync_plugins() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_plugins_admitted(plugin_manager: PluginManager, loop) -> bool:
|
||||
async def _sync_plugins_admitted(
|
||||
plugin_manager: PluginManager,
|
||||
loop,
|
||||
startup_token: PluginStartupLease | None,
|
||||
online_restore_plugins: set[str],
|
||||
) -> bool:
|
||||
"""在一个 admission lease 内完成包、依赖、实例和动态路由同步。"""
|
||||
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
||||
sync_result = await execute_task(
|
||||
loop,
|
||||
lambda: plugin_manager.sync(
|
||||
startup_token,
|
||||
online_restore_plugins=online_restore_plugins,
|
||||
),
|
||||
"插件同步到本地",
|
||||
)
|
||||
dependency_result = await (
|
||||
plugin_manager.async_install_plugin_missing_dependencies_with_status()
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.application.plugin.recovery import get_plugin_installation_recovery
|
||||
from app.startup.initializers.cache import configure_cache_dependencies
|
||||
|
||||
# 缓存装饰器会在业务模块导入时创建后端,必须先完成适配器装配。
|
||||
@@ -96,8 +97,8 @@ async def init_extra():
|
||||
return
|
||||
plugin_manager = get_plugin_manager()
|
||||
try:
|
||||
async with plugin_lifecycle.hold_startup():
|
||||
if await sync_plugins():
|
||||
async with plugin_lifecycle.hold_startup() as startup_token:
|
||||
if await sync_plugins(startup_token):
|
||||
await execute_task(
|
||||
global_vars.loop,
|
||||
init_plugin_scheduler,
|
||||
@@ -305,10 +306,11 @@ async def stop_task_registry(app: FastAPI) -> bool:
|
||||
return await task_registry.shutdown(timeout_seconds=30.0)
|
||||
|
||||
|
||||
def prepare_plugin_restore() -> None:
|
||||
"""先装配插件外部系统服务,再恢复插件及其依赖。"""
|
||||
async def prepare_plugin_restore() -> None:
|
||||
"""先恢复未完成安装事务,再加载持久插件备份及其依赖。"""
|
||||
configure_plugin_services()
|
||||
SystemChain().restore_plugins()
|
||||
await get_plugin_installation_recovery().replay()
|
||||
await run_in_threadpool_to_completion(SystemChain().restore_plugins)
|
||||
|
||||
|
||||
def schedule_plugin_settlement(app: FastAPI) -> None:
|
||||
|
||||
Reference in New Issue
Block a user