diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index 90d4730aa2..01e1722359 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import errno import hashlib import io +import os import re import shutil import stat @@ -43,6 +45,56 @@ from app.runtime.settings import get_runtime_setting from app.runtime.version import get_app_version +# 判定插件从已装版本切换到另一版本能否被安装期接受,返回拒绝说明或 None +VersionSwitchGuard = Callable[[str, Path, Path], Optional[str]] + + +def _allow_version_switch(_pid: str, _plugin_dir: Path, _source_dir: Path) -> Optional[str]: + """未装配版本并存检查端口时不拦截安装,保持今天的单版本行为。""" + return None + + +@dataclass(frozen=True, slots=True) +class PluginInstallVersionTarget: + """已就位暂存内容应当落盘的版本子目录与登记用版本号。""" + + subdirectory: str + version: str + + +# 判定已就位的暂存内容应当写入插件根目录下的哪个子目录,必要时原地迁移存量平铺 +# 布局;返回 None 表示直接写入插件根目录本身(平铺布局,不登记版本元信息) +InstallTargetResolver = Callable[[str, Path, Path], Optional[PluginInstallVersionTarget]] + + +def _flat_install_target( + _pid: str, _plugin_dir: Path, _staged_source_dir: Path +) -> Optional[PluginInstallVersionTarget]: + """未装配版本目录解析端口时落回平铺布局,保持今天的单版本覆盖安装行为。""" + return None + + +# 把已落盘的版本目录登记进版本元信息并置为当前版本,返回登记前的当前版本号(插件 +# 在本次登记前没有任何已装版本时为 None),供失败清理精确复原:(插件根目录, 版本号, 来源标签) +InstallVersionRegistrar = Callable[[Path, str, str], Optional[str]] + + +def _noop_version_registrar(_plugin_dir: Path, _version: str, _source: str) -> Optional[str]: + """未装配版本元信息登记端口时不写入 versions.json,保持平铺布局行为。""" + return None + + +# 安装失败时回滚单个版本目录及其版本元信息登记,把当前版本精确复原为登记前的值, +# 不牵连插件的其它已装版本:(插件根目录, 版本号, 登记前的当前版本号) +InstallVersionRollback = Callable[[Path, str, Optional[str]], None] + + +def _noop_version_rollback( + _plugin_dir: Path, _version: str, _previous_current: Optional[str] +) -> None: + """未装配版本回滚端口时不清理版本目录,保持平铺布局下由整根清理兜底的行为。""" + return None + class PluginPackageSourcePort(Protocol): """声明包 owner 读取市场元数据和远端制品所需的外部端口。""" @@ -171,6 +223,32 @@ class _RemotePluginInstallSelection: fallback_to_filelist: bool +@dataclass(frozen=True, slots=True) +class _PluginContentPlacement: + """记录暂存内容落位尝试的结果,供安装流程判断失败时是否需要清理。 + + 换入子步骤本身失败时,最终目录已经由换入函数恢复到换入前的状态,此时 + ``swap_committed`` 为 False,调用方绝不能再清理,否则会删掉刚恢复好的 + 旧内容;换入已经成功、之后的版本元信息登记才失败时,最终目录确实被 + 本次安装改动过,``swap_committed`` 为 True,调用方需要按既有语义清理 + 本次安装写入的版本目录。 + + :param content_dir: 落盘后的内容目录;失败时为 None + :param message: 失败信息;成功时为空串 + :param target: 已解析的版本化安装目标;平铺布局时为 None + :param swap_committed: 换入步骤是否已把新内容写入最终目录 + :param previous_current: 版本元信息登记端口返回的登记前当前版本号,供 + 失败清理精确复原当前版本;平铺布局、登记未执行或登记端口自身失败 + 时为 None + """ + + content_dir: Optional[Path] + message: str + target: Optional[PluginInstallVersionTarget] + swap_committed: bool + previous_current: Optional[str] + + class PluginPackageManager: """隔离插件包安装、本地同步、分身改写和文件补偿能力。""" @@ -182,11 +260,31 @@ def __init__( *, health: Optional[PluginRuntimeHealth] = None, plugin_root: Optional[Path] = None, + version_switch_guard: VersionSwitchGuard = _allow_version_switch, + install_target_resolver: InstallTargetResolver = _flat_install_target, + install_version_registrar: InstallVersionRegistrar = _noop_version_registrar, + install_version_rollback: InstallVersionRollback = _noop_version_rollback, ) -> None: - """保存外部来源端口和依赖健康 owner。""" + """保存外部来源端口、依赖健康 owner 和版本目录布局相关的注入端口。 + + 版本写法体检、目标目录决策、版本元信息登记和失败回滚都依赖运行时扩展 + 包,不属于适配器层职责,因此只接受可注入的端口;未注入时全部退化为 + 今天的单版本平铺覆盖安装行为。 + :param source: 市场元数据与制品来源端口 + :param health: 依赖健康 owner + :param plugin_root: 插件根目录,未注入时按运行配置解析 + :param version_switch_guard: 判定版本切换能否被接受的端口 + :param install_target_resolver: 判定暂存内容落盘子目录的端口 + :param install_version_registrar: 登记已落盘版本元信息的端口 + :param install_version_rollback: 安装失败时回滚单个版本目录的端口 + """ self._source = source self._health = health or PluginRuntimeHealth() self._plugin_root = plugin_root.resolve() if plugin_root else None + self._version_switch_guard = version_switch_guard + self._install_target_resolver = install_target_resolver + self._install_version_registrar = install_version_registrar + self._install_version_rollback = install_version_rollback def _require_source(self) -> PluginPackageSourcePort: """返回已装配来源端口,未完成组合时拒绝执行包写入。""" @@ -961,11 +1059,12 @@ def install_raw(self, pid: str, repo_url: str, package_version: Optional[str] = release_tag = selection.release_tag if release_tag and not selection.fallback_to_filelist: - def prepare_selected_release() -> tuple[bool, str]: + def prepare_selected_release(staging_dir: Path) -> tuple[bool, str]: return self.__install_from_release( pid, selection.user_repo, release_tag, + staging_dir, ) return self.__install_flow_sync( @@ -978,20 +1077,22 @@ def prepare_selected_release() -> tuple[bool, str]: if release_tag: # 当前索引 Release 失败时回退文件列表,避免发布产物短暂滞后阻断安装。 - def prepare_release() -> tuple[bool, str]: + def prepare_release(staging_dir: Path) -> tuple[bool, str]: ok, msg = self.__install_from_release( pid, selection.user_repo, release_tag, + staging_dir, ) if ok: return True, msg logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}") - self.__remove_old_plugin(pid) + shutil.rmtree(staging_dir, ignore_errors=True) return self.__prepare_content_via_filelist_sync( pid, selection.user_repo, selection.package_version, + staging_dir, ) return self.__install_flow_sync( @@ -1002,11 +1103,12 @@ def prepare_release() -> tuple[bool, str]: before_dependency_install, ) # 未声明 release 打包的插件继续使用文件列表方式安装。 - def prepare_filelist() -> tuple[bool, str]: + def prepare_filelist(staging_dir: Path) -> tuple[bool, str]: return self.__prepare_content_via_filelist_sync( pid, selection.user_repo, selection.package_version, + staging_dir, ) return self.__install_flow_sync( @@ -1051,8 +1153,10 @@ def remove_plugin(self, plugin_id: str) -> bool: def install_from_release( self, plugin_id: str, user_repo: str, release_tag: str ) -> tuple[bool, str]: - """提供给兼容 Facade 的 Release 制品安装入口。""" - return self.__install_from_release(plugin_id, user_repo, release_tag) + """提供给兼容 Facade 的 Release 制品安装入口,直接写入插件运行目录。""" + return self.__install_from_release( + plugin_id, user_repo, release_tag, self.__plugin_dir(plugin_id) + ) def __install_local_package( self, @@ -1086,18 +1190,18 @@ def __install_local_package( if not isinstance(raw_source_path, (str, Path)): return False, "本地插件来源路径无效" source_dir = Path(raw_source_path) - dest_dir = self._plugins_root() / pid.lower() + dest_dir = self.__plugin_dir(pid) try: if source_dir.resolve() == dest_dir.resolve(): return False, "本地插件来源不能与运行目录相同" except Exception: return False, "本地插件来源路径无效" - def prepare_local() -> tuple[bool, str]: + def prepare_local(staging_dir: Path) -> tuple[bool, str]: try: shutil.copytree( source_dir, - dest_dir, + staging_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules") ) @@ -1110,6 +1214,7 @@ def prepare_local() -> tuple[bool, str]: pid=pid, force_install=force_install, prepare_content=prepare_local, + source_label="local", repo_url=repo_url or self.make_local_repo_url( pid, ( @@ -1173,8 +1278,9 @@ def __resolve_download_file( pid: str, remote_path: object, package_version: Optional[str], + dest_root: Path, ) -> Path: - """把市场文件路径限定到当前插件目录,拒绝绝对路径和目录穿越。""" + """把市场文件路径限定到给定目标根目录之下,拒绝绝对路径和目录穿越。""" if not isinstance(remote_path, str) or not remote_path or "\\" in remote_path: raise ValueError("插件文件路径无效") pure_path = PurePosixPath(remote_path) @@ -1189,19 +1295,20 @@ def __resolve_download_file( or any(part in {"", ".", ".."} for part in parts[2:]) ): raise ValueError("插件文件路径无效") - plugin_dir = (self._plugins_root() / pid.lower()).resolve() - file_path = (plugin_dir / Path(*parts[2:])).resolve() - if not file_path.is_relative_to(plugin_dir): + resolved_root = dest_root.resolve() + file_path = (resolved_root / Path(*parts[2:])).resolve() + if not file_path.is_relative_to(resolved_root): raise ValueError("插件文件路径无效") return file_path def __download_files(self, pid: str, file_list: list[dict[str, Any]], user_repo: str, - package_version: Optional[str] = None) -> tuple[bool, str]: + package_version: Optional[str], dest_root: Path) -> tuple[bool, str]: """ 下载插件文件 :param pid: 插件 ID :param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接) :param user_repo: GitHub 仓库的 user/repo 路径 + :param dest_root: 文件落盘的目标根目录 :return: (是否成功, 错误信息) """ if not file_list: @@ -1221,6 +1328,7 @@ def __download_files(self, pid: str, file_list: list[dict[str, Any]], user_repo: pid, item.get("path"), package_version, + dest_root, ) except ValueError as error: return False, str(error) @@ -1258,16 +1366,17 @@ def __download_files(self, pid: str, file_list: list[dict[str, Any]], user_repo: def __install_dependencies_if_required( self, pid: str, + content_dir: Path, before_dependency_install: Optional[Callable[[], None]] = None, ) -> tuple[bool, bool, str]: """ 安装插件依赖。 :param pid: 插件 ID + :param content_dir: 插件本次已落盘的源码目录 :return: (是否存在依赖,安装是否成功, 错误信息) """ - plugin_dir = self._plugins_root() / pid.lower() try: - manifest = load_dependency_manifest(plugin_dir) + manifest = load_dependency_manifest(content_dir) except PluginDependencyManifestError as error: logger.error(f"{pid} 依赖清单无效:{error}") return True, False, str(error) @@ -1332,6 +1441,32 @@ def __remove_old_plugin(self, pid: str) -> None: if plugin_dir.exists(): shutil.rmtree(plugin_dir, ignore_errors=True) + def __cleanup_failed_install( + self, + pid: str, + plugin_dir: Path, + target: Optional[PluginInstallVersionTarget], + previous_current: Optional[str], + ) -> None: + """安装失败且未走备份还原时按目标类型收敛清理范围,同步流程使用。 + + 平铺布局(target 为 None)沿用清理插件根目录的既有行为;版本化布局 + 委托注入的回滚端口只清理本次安装尝试写入的那一个版本目录,插件下的 + 其它已装版本与版本元信息不受影响,当前版本精确复原为登记前的值。 + + :param pid: 插件 ID + :param plugin_dir: 插件根目录 + :param target: 本次安装解析出的版本化安装目标;平铺布局时为 None + :param previous_current: 版本元信息登记前的当前版本号,供回滚端口 + 精确复原当前版本;平铺布局或登记未执行时为 None + """ + if target is None: + self.__remove_old_plugin(pid) + logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装") + return + self._install_version_rollback(plugin_dir, target.version, previous_current) + logger.warn(f"{pid} 已清理版本 {target.version} 对应安装目录,请尝试重新安装") + def refresh_persistent_backup(self, pid: str) -> bool: """ 刷新插件持久化备份目录,供 docker 重置后恢复使用 @@ -1393,56 +1528,189 @@ def __get_plugin_meta(self, pid: str, repo_url: str, logger.error(f"获取插件 {pid} 元数据失败:{e}") return {} + def __new_install_staging_dir(self, pid: str) -> Path: + """分配一个全新的安装暂存目录,用于在触碰插件根目录前完整准备待装内容。""" + return ( + Path(get_runtime_setting('TEMP_PATH')) + / "plugin_install_staging" + / f"{pid.lower()}-{uuid.uuid4().hex}" + ) + + @staticmethod + def __swap_staged_plugin_content(staging_dir: Path, final_dir: Path) -> None: + """把已就位的暂存内容换入最终目录,任一步失败都保留换入前的目录内容。 + + 与既有的运行目录补偿替换手法一致(见 __restore_tree):先把旧目标改名 + 挪到同级临时位置,暂存内容改名落位后再删除旧目标;只要新内容还没落位, + 旧目标就仍然完整,因此中途失败可以原样退回。跨设备无法原子改名时退化 + 为复制加删除:复制期间随时可能中途失败留下半份 final_dir,因此复制失败 + 时先删掉这份半成品,再把旧目标换回 final_dir 位置,只有复制确认完整 + 落地后才删除旧目标;回滚换回旧目标本身也可能失败,此时只记录旧目标的 + 保留位置、不覆盖原始异常,避免看起来"改名成功"实则数据已丢的假象。 + + :param staging_dir: 已就位的待安装内容目录 + :param final_dir: 最终写入目标目录,可能已存在旧内容 + :raise OSError: 改名或复制失败,且已尽力保留或恢复换入前的目录内容 + """ + final_dir.parent.mkdir(parents=True, exist_ok=True) + previous = final_dir.parent / f".{final_dir.name}.previous-{uuid.uuid4().hex}" + had_previous_content = final_dir.exists() + previous_kept_for_manual_recovery = False + try: + if had_previous_content: + os.rename(final_dir, previous) + try: + os.rename(staging_dir, final_dir) + return + except OSError as error: + if getattr(error, "errno", None) != errno.EXDEV: + raise + logger.warning( + f"插件安装内容跨设备无法原子改名,退化为复制后删除:" + f"{staging_dir} -> {final_dir} - {error}" + ) + try: + shutil.copytree(staging_dir, final_dir) + except OSError: + if final_dir.exists(): + shutil.rmtree(final_dir, ignore_errors=True) + raise + shutil.rmtree(staging_dir, ignore_errors=True) + except OSError as error: + if had_previous_content and previous.exists(): + try: + if final_dir.exists(): + shutil.rmtree(final_dir, ignore_errors=True) + os.rename(previous, final_dir) + except OSError as rollback_error: + previous_kept_for_manual_recovery = True + logger.error( + f"插件安装换入失败后恢复换入前内容也失败," + f"换入前内容保留在 {previous}:{rollback_error}" + ) + raise error from rollback_error + raise + finally: + if previous.exists() and not previous_kept_for_manual_recovery: + shutil.rmtree(previous, ignore_errors=True) + + def __place_staged_plugin_content( + self, + pid: str, + plugin_dir: Path, + staging_dir: Path, + source_label: str, + ) -> _PluginContentPlacement: + """决定暂存内容的落盘子目录、原子换入,并在写入版本目录时登记版本元信息。 + + 目标目录决策委托给注入的解析端口,必要时该端口会原地迁移存量平铺布局; + 本方法只负责在决策就绪后做机械的目录换入和登记,不重复做写法体检。 + + :param pid: 插件 ID + :param plugin_dir: 插件根目录 + :param staging_dir: 已就位的暂存内容目录 + :param source_label: 登记版本元信息使用的来源标签 + :return: 落位结果;``content_dir`` 为 None 表示失败,调用方据此判断 + 失败清理范围——``target`` 为 None 时是平铺布局,需清理插件根 + 目录,非 None 时只需清理该版本目录;``swap_committed`` 为 False + 时最终目录已由换入步骤自身恢复,调用方不得再清理;``previous_current`` + 为登记端口返回的登记前当前版本号,供失败清理精确复原当前版本 + """ + try: + target = self._install_target_resolver(pid, plugin_dir, staging_dir) + except Exception as error: # noqa: BLE001 - 组合根注入的解析端口失败按安装失败处理 + return _PluginContentPlacement( + None, f"解析插件安装目标失败:{error}", None, False, None + ) + + final_dir = plugin_dir if target is None else plugin_dir / target.subdirectory + try: + self.__swap_staged_plugin_content(staging_dir, final_dir) + except OSError as error: + return _PluginContentPlacement( + None, f"写入插件内容失败:{error}", target, False, None + ) + + previous_current: Optional[str] = None + if target is not None: + try: + previous_current = self._install_version_registrar( + plugin_dir, target.version, source_label + ) + except Exception as error: # noqa: BLE001 - 组合根注入的登记端口失败按安装失败处理 + return _PluginContentPlacement( + None, f"登记插件版本元信息失败:{error}", target, True, None + ) + + return _PluginContentPlacement(final_dir, "", target, True, previous_current) + def __install_flow_sync( self, pid: str, force_install: bool, - prepare_content: Callable[[], tuple[bool, str]], + prepare_content: Callable[[Path], tuple[bool, str]], repo_url: Optional[str] = None, before_dependency_install: Optional[Callable[[], None]] = None, + source_label: str = "market", ) -> tuple[bool, str]: """ - 同步安装统一流程:备份→清理→准备内容→安装依赖→上报 - prepare_content 负责把插件文件放到 app/plugins/{pid} + 同步安装统一流程:暂存内容→并存检查→备份→落位→安装依赖→上报 + prepare_content 负责把插件文件放到调用时给定的暂存目录;只有暂存内容 + 齐备且并存检查通过后,才会触碰插件根目录,任一步失败插件根目录都保持 + 改动前的状态。 """ - backup_dir = None - if not force_install: - backup_dir = self.__backup_plugin(pid) + plugin_dir = self.__plugin_dir(pid) + staging_dir = self.__new_install_staging_dir(pid) + try: + success, message = prepare_content(staging_dir) + if not success: + logger.error(f"{pid} 准备插件内容失败:{message}") + return False, message - self.__remove_old_plugin(pid) + rejection = self._version_switch_guard(pid, plugin_dir, staging_dir) + if rejection: + logger.warning(f"{pid} 安装被并存检查拒绝:{rejection}") + return False, rejection - success, message = prepare_content() - if not success: - logger.error(f"{pid} 准备插件内容失败:{message}") - if backup_dir: - self.__restore_plugin(pid, backup_dir) - logger.warn(f"{pid} 插件安装失败,已还原备份插件") - else: - self.__remove_old_plugin(pid) - logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装") - return False, message + backup_dir = None + if not force_install: + backup_dir = self.__backup_plugin(pid) - dependencies_exist, dep_ok, dep_msg = ( - self.__install_dependencies_if_required( - pid, - before_dependency_install, + placement = self.__place_staged_plugin_content( + pid, plugin_dir, staging_dir, source_label, ) - if before_dependency_install is not None - else self.__install_dependencies_if_required(pid) - ) - if dependencies_exist and not dep_ok: - logger.error(f"{pid} 依赖安装失败:{dep_msg}") - if backup_dir: - self.__restore_plugin(pid, backup_dir) - logger.warn(f"{pid} 插件安装失败,已还原备份插件") - else: - self.__remove_old_plugin(pid) - logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装") - return False, dep_msg + content_dir, target = placement.content_dir, placement.target + if content_dir is None: + logger.error(f"{pid} 落位插件内容失败:{placement.message}") + if backup_dir: + self.__restore_plugin(pid, backup_dir) + logger.warn(f"{pid} 插件安装失败,已还原备份插件") + elif placement.swap_committed: + self.__cleanup_failed_install( + pid, plugin_dir, target, placement.previous_current, + ) + return False, placement.message - if backup_dir: - shutil.rmtree(backup_dir, ignore_errors=True) - return True, "" + dependencies_exist, dep_ok, dep_msg = self.__install_dependencies_if_required( + pid, content_dir, before_dependency_install, + ) + if dependencies_exist and not dep_ok: + logger.error(f"{pid} 依赖安装失败:{dep_msg}") + if backup_dir: + self.__restore_plugin(pid, backup_dir) + logger.warn(f"{pid} 插件安装失败,已还原备份插件") + else: + self.__cleanup_failed_install( + pid, plugin_dir, target, placement.previous_current, + ) + return False, dep_msg + + if backup_dir: + shutil.rmtree(backup_dir, ignore_errors=True) + return True, "" + finally: + if staging_dir.exists(): + shutil.rmtree(staging_dir, ignore_errors=True) @staticmethod def __validate_release_zip_name(name: str) -> None: @@ -1533,11 +1801,13 @@ def __iter_release_zip_targets( targets.append((info, dest_path, info.is_dir())) return targets - def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> tuple[bool, str]: + def __install_from_release( + self, pid: str, user_repo: str, release_tag: str, dest_root: Path + ) -> tuple[bool, str]: """ 通过 GitHub Release 资产文件安装插件。 规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件; - 将其全部解压到 app/plugins/{pid} + 将其全部解压到 dest_root """ # 拼接资产文件名 asset_name = f"{release_tag.lower()}.zip" @@ -1583,8 +1853,7 @@ def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> infos = zf.infolist() if not infos: return False, "压缩包内容为空" - dest_base = self._plugins_root() / pid.lower() - targets = self.__iter_release_zip_targets(zf, dest_base) + targets = self.__iter_release_zip_targets(zf, dest_root) wrote_any = False for info, dest_path, is_dir in targets: if is_dir: @@ -1638,12 +1907,13 @@ async def __async_get_file_list(self, pid: str, user_repo: str, package_version: return None, "插件数据解析失败" async def __async_download_files(self, pid: str, file_list: list[dict[str, Any]], user_repo: str, - package_version: Optional[str] = None) -> tuple[bool, str]: + package_version: Optional[str], dest_root: Path) -> tuple[bool, str]: """ 异步下载插件文件 :param pid: 插件 ID :param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接) :param user_repo: GitHub 仓库的 user/repo 路径 + :param dest_root: 文件落盘的目标根目录 :return: (是否成功, 错误信息) """ if not file_list: @@ -1663,6 +1933,7 @@ async def __async_download_files(self, pid: str, file_list: list[dict[str, Any]] pid, item.get("path"), package_version, + dest_root, ) except ValueError as error: return False, str(error) @@ -1748,6 +2019,30 @@ async def __async_remove_old_plugin(self, pid: str) -> None: if await plugin_dir.exists(): await aioshutil.rmtree(plugin_dir, ignore_errors=True) + async def __async_cleanup_failed_install( + self, + pid: str, + plugin_dir: Path, + target: Optional[PluginInstallVersionTarget], + previous_current: Optional[str], + ) -> None: + """安装失败且未走备份还原时按目标类型收敛清理范围,异步流程使用,语义与同步方法一致。 + + :param pid: 插件 ID + :param plugin_dir: 插件根目录 + :param target: 本次安装解析出的版本化安装目标;平铺布局时为 None + :param previous_current: 版本元信息登记前的当前版本号,供回滚端口 + 精确复原当前版本;平铺布局或登记未执行时为 None + """ + if target is None: + await self.__async_remove_old_plugin(pid) + logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") + return + await _await_thread_operation( + self._install_version_rollback, plugin_dir, target.version, previous_current, + ) + logger.warning(f"{pid} 已清理版本 {target.version} 对应安装目录,请尝试重新安装") + async def _async_copytree(self, src: AsyncPath, dst: AsyncPath) -> None: """ 异步递归复制目录 @@ -1772,16 +2067,17 @@ async def _async_copytree(self, src: AsyncPath, dst: AsyncPath) -> None: async def __async_install_dependencies_if_required( self, pid: str, + content_dir: Path, before_dependency_install: Optional[Callable[[], None]] = None, ) -> tuple[bool, bool, str]: """ 异步安装插件依赖。 :param pid: 插件 ID + :param content_dir: 插件本次已落盘的源码目录 :return: (是否存在依赖,安装是否成功, 错误信息) """ - plugin_dir = self._plugins_root() / pid.lower() try: - manifest = load_dependency_manifest(plugin_dir) + manifest = load_dependency_manifest(content_dir) except PluginDependencyManifestError as error: logger.error(f"{pid} 依赖清单无效:{error}") return True, False, str(error) @@ -1861,11 +2157,12 @@ async def async_install_raw( release_tag = selection.release_tag if release_tag and not selection.fallback_to_filelist: - async def prepare_selected_release() -> tuple[bool, str]: + async def prepare_selected_release(staging_dir: Path) -> tuple[bool, str]: return await self.__async_install_from_release( pid, selection.user_repo, release_tag, + staging_dir, ) return await self.__install_flow_async( @@ -1878,20 +2175,22 @@ async def prepare_selected_release() -> tuple[bool, str]: if release_tag: # 当前索引 Release 失败时回退文件列表,保持同步与异步安装一致。 - async def prepare_release() -> tuple[bool, str]: + async def prepare_release(staging_dir: Path) -> tuple[bool, str]: ok, msg = await self.__async_install_from_release( pid, selection.user_repo, release_tag, + staging_dir, ) if ok: return True, msg logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}") - await self.__async_remove_old_plugin(pid) + await aioshutil.rmtree(staging_dir, ignore_errors=True) return await self.__prepare_content_via_filelist_async( pid, selection.user_repo, selection.package_version, + staging_dir, ) return await self.__install_flow_async( @@ -1902,11 +2201,12 @@ async def prepare_release() -> tuple[bool, str]: before_dependency_install, ) # 未声明 release 打包的插件继续使用文件列表方式安装。 - async def prepare_filelist() -> tuple[bool, str]: + async def prepare_filelist(staging_dir: Path) -> tuple[bool, str]: return await self.__prepare_content_via_filelist_async( pid, selection.user_repo, selection.package_version, + staging_dir, ) return await self.__install_flow_async( @@ -1949,9 +2249,9 @@ async def async_remove_plugin(self, plugin_id: str) -> None: async def async_install_from_release( self, plugin_id: str, user_repo: str, release_tag: str ) -> tuple[bool, str]: - """提供给兼容 Facade 的异步 Release 制品安装入口。""" + """提供给兼容 Facade 的异步 Release 制品安装入口,直接写入插件运行目录。""" return await self.__async_install_from_release( - plugin_id, user_repo, release_tag + plugin_id, user_repo, release_tag, self.__plugin_dir(plugin_id) ) async def __async_get_plugin_meta(self, pid: str, repo_url: str, @@ -1972,38 +2272,59 @@ async def __install_flow_async( self, pid: str, force_install: bool, - prepare_content: Callable[[], Awaitable[tuple[bool, str]]], + prepare_content: Callable[[Path], Awaitable[tuple[bool, str]]], repo_url: Optional[str] = None, before_dependency_install: Optional[Callable[[], None]] = None, ) -> tuple[bool, str]: """ - 异步安装流程,处理插件内容准备、依赖安装和注册 + 异步安装流程:暂存内容→并存检查→备份→落位→安装依赖→上报 + prepare_content 负责把插件文件放到调用时给定的暂存目录;只有暂存内容 + 齐备且并存检查通过后,才会触碰插件根目录,任一步失败插件根目录都保持 + 改动前的状态。 """ + plugin_dir = self.__plugin_dir(pid) + staging_dir = self.__new_install_staging_dir(pid) backup_dir = None try: + success, message = await prepare_content(staging_dir) + if not success: + logger.error(f"{pid} 准备插件内容失败:{message}") + return False, message + + rejection = await _await_thread_operation( + self._version_switch_guard, pid, plugin_dir, staging_dir, + ) + if rejection: + logger.warning(f"{pid} 安装被并存检查拒绝:{rejection}") + return False, rejection + if not force_install: backup_dir = await self.__async_backup_plugin(pid) - await self.__async_remove_old_plugin(pid) - - success, message = await prepare_content() - if not success: - logger.error(f"{pid} 准备插件内容失败:{message}") + placement = cast( + _PluginContentPlacement, + await _await_thread_operation( + self.__place_staged_plugin_content, + pid, + plugin_dir, + staging_dir, + "market", + ), + ) + content_dir, target = placement.content_dir, placement.target + if content_dir is None: + logger.error(f"{pid} 落位插件内容失败:{placement.message}") if backup_dir: await self.__async_restore_plugin(pid, backup_dir) logger.warning(f"{pid} 插件安装失败,已还原备份插件") - else: - await self.__async_remove_old_plugin(pid) - logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") - return False, message + elif placement.swap_committed: + await self.__async_cleanup_failed_install( + pid, plugin_dir, target, placement.previous_current, + ) + return False, placement.message - dependencies_exist, dep_ok, dep_msg = ( - await self.__async_install_dependencies_if_required( - pid, - before_dependency_install, - ) - if before_dependency_install is not None - else await self.__async_install_dependencies_if_required(pid) + dependencies_exist, dep_ok, dep_msg = await self.__async_install_dependencies_if_required( + pid, content_dir, before_dependency_install, ) if dependencies_exist and not dep_ok: logger.error(f"{pid} 依赖安装失败:{dep_msg}") @@ -2011,8 +2332,9 @@ async def __install_flow_async( await self.__async_restore_plugin(pid, backup_dir) logger.warning(f"{pid} 插件安装失败,已还原备份插件") else: - await self.__async_remove_old_plugin(pid) - logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装") + await self.__async_cleanup_failed_install( + pid, plugin_dir, target, placement.previous_current, + ) return False, dep_msg return True, "" @@ -2024,9 +2346,12 @@ async def __install_flow_async( finally: if backup_dir: await aioshutil.rmtree(backup_dir, ignore_errors=True) + if staging_dir.exists(): + await aioshutil.rmtree(staging_dir, ignore_errors=True) def __prepare_content_via_filelist_sync(self, pid: str, user_repo: str, - package_version: Optional[str]) -> tuple[bool, str]: + package_version: Optional[str], + dest_root: Path) -> tuple[bool, str]: """ 同步准备插件内容,通过文件列表获取插件文件和依赖 """ @@ -2036,13 +2361,14 @@ def __prepare_content_via_filelist_sync(self, pid: str, user_repo: str, if msg == "插件源码目录不存在": return False, f"{pid} {msg}" return False, msg or "插件文件列表读取失败" - ok, m = self.__download_files(runtime_pid, file_list, user_repo, package_version) + ok, m = self.__download_files(runtime_pid, file_list, user_repo, package_version, dest_root) if not ok: return False, m return True, "" async def __prepare_content_via_filelist_async(self, pid: str, user_repo: str, - package_version: Optional[str]) -> tuple[bool, str]: + package_version: Optional[str], + dest_root: Path) -> tuple[bool, str]: """ 异步准备插件内容,通过文件列表获取插件文件和依赖 """ @@ -2061,16 +2387,19 @@ async def __prepare_content_via_filelist_async(self, pid: str, user_repo: str, file_list, user_repo, package_version, + dest_root, ) if not ok: return False, m return True, "" - async def __async_install_from_release(self, pid: str, user_repo: str, release_tag: str) -> tuple[bool, str]: + async def __async_install_from_release( + self, pid: str, user_repo: str, release_tag: str, dest_root: Path + ) -> tuple[bool, str]: """ 通过 GitHub Release 资产文件安装插件(异步)。 规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件; - 将其全部解压到 app/plugins/{pid} + 将其全部解压到 dest_root """ # 拼接资产文件名 asset_name = f"{release_tag.lower()}.zip" @@ -2118,8 +2447,7 @@ async def __async_install_from_release(self, pid: str, user_repo: str, release_t infos = zf.infolist() if not infos: return False, "压缩包内容为空" - dest_base = self._plugins_root() / pid.lower() - targets = self.__iter_release_zip_targets(zf, dest_base) + targets = self.__iter_release_zip_targets(zf, dest_root) wrote_any = False for info, dest_path, is_dir in targets: async_dest_path = AsyncPath(dest_path) diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index 2802aeb9d2..b346b2cfcd 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -538,6 +538,26 @@ def _user_write( _write("plugin.folder.create", recovery=RecoveryMode.TRANSACTION), _write("plugin.folder.delete", effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=_DELETE_RECOVERABLE), _write("plugin.folder.plugins.update", recovery=RecoveryMode.TRANSACTION), + _admin_read("plugin.versions.get", sensitivity=ResultSensitivity.PRIVATE), + _spec( + "plugin.versions.set_instance", + effect=ActionEffect.EXTERNAL_SIDE_EFFECT, + required_role=_ADMIN, + confirmation=_CONFIRM, + recovery=RecoveryMode.RECONCILE, + ), + _spec( + "plugin.versions.recycle", + effect=ActionEffect.EXTERNAL_SIDE_EFFECT, + required_role=_ADMIN, + confirmation=_CONFIRM, + recovery=RecoveryMode.RECONCILE, + ), + _admin_read("plugin.loglevel.get", sensitivity=ResultSensitivity.PRIVATE), + _write("plugin.loglevel.set"), + _write("plugin.loglevel.clear"), + _write("plugin.default_target.set"), + _write("plugin.default_target.clear"), ) @@ -755,6 +775,26 @@ def _user_write( "plugin.folder.create": ApiOperationRoute("POST", "/api/v1/plugin/folders/{folder_name}"), "plugin.folder.delete": ApiOperationRoute("DELETE", "/api/v1/plugin/folders/{folder_name}"), "plugin.folder.plugins.update": ApiOperationRoute("PUT", "/api/v1/plugin/folders/{folder_name}/plugins"), + "plugin.versions.get": ApiOperationRoute("GET", "/api/v1/plugin/versions/{plugin_id}"), + "plugin.versions.set_instance": ApiOperationRoute( + "PUT", "/api/v1/plugin/versions/{plugin_id}/{instance_id}" + ), + "plugin.versions.recycle": ApiOperationRoute( + "POST", "/api/v1/plugin/versions/{plugin_id}/recycle" + ), + "plugin.loglevel.get": ApiOperationRoute("GET", "/api/v1/plugin/loglevel/{plugin_id}"), + "plugin.loglevel.set": ApiOperationRoute( + "PUT", "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}" + ), + "plugin.loglevel.clear": ApiOperationRoute( + "DELETE", "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}" + ), + "plugin.default_target.set": ApiOperationRoute( + "PUT", "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target" + ), + "plugin.default_target.clear": ApiOperationRoute( + "DELETE", "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target" + ), } diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 3b2e4891f9..1e5f8545fa 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -209,6 +209,14 @@ "plugin.folder.create": "Create one named plugin folder.", "plugin.folder.delete": "Delete one named plugin folder without uninstalling its plugins.", "plugin.folder.plugins.update": "Replace the ordered plugin IDs assigned to one named plugin folder.", + "plugin.versions.get": "List one plugin's installed source versions and each instance's version binding.", + "plugin.versions.set_instance": "Set one plugin instance's version binding and restart it to apply the change.", + "plugin.versions.recycle": "Delete one plugin's installed source versions that are unreferenced and outside the retention window.", + "plugin.loglevel.get": "List one plugin's instances, including its host binding, with each instance's configured and effective log level.", + "plugin.loglevel.set": "Set one plugin instance's log-level override, taking effect immediately without following the global log level.", + "plugin.loglevel.clear": "Clear one plugin instance's log-level override so it immediately follows the global log level again.", + "plugin.default_target.set": "Set one plugin instance as the plugin's default call target, automatically clearing any previous default.", + "plugin.default_target.clear": "Clear one plugin instance's default-call-target flag, only if it is the plugin's current default.", } @@ -245,6 +253,7 @@ "completed_episode": "Highest episode number already completed for the subscription.", "current_site_id": "Configured site ID currently handling the subscription execution.", "cookie": "Site authentication cookie. Treat this value as a secret.", + "configured_level": "Plugin instance's own configured log-level override, null when unset or expired.", "count": "Maximum number of records to return on the requested page.", "current_audio_format": "Audio format of the best version currently held.", "current_bit_depth": "Bit depth of the best version currently held.", @@ -272,6 +281,7 @@ "douban_sort": "Douban Music category order: U for comprehensive, S for rating, R for newest, or O for hottest.", "drive_id": "Provider-native storage drive identifier.", "effect": "Video or release-effect filter expression used by the subscription.", + "effective_level": "Plugin instance's actual log level after resolving its override and expiry against the global log level.", "error": "Human-readable workflow, provider, or execution error message.", "entity": "Music exploration entity: recording for tracks or album for release groups.", "enclosure": "Torrent download URL or enclosure supplied by the indexer result.", @@ -284,6 +294,7 @@ "episodes": "Episode-number expression recorded in history, such as E01-E03.", "errmsg": "Error message recorded for a failed transfer.", "expected_revision": "Exact current plugin source-identity revision returned by plugin.source.options.", + "expires_at": "Expiry timestamp for a plugin instance's log-level override; null means it never expires.", "exclude": "Regular expression or filter expression that rejects matching releases.", "extension": "Filename extension, including or excluding the leading dot as returned by storage.", "fileid": "Provider-native storage item identifier.", @@ -292,6 +303,7 @@ "files": "Serialized list of files recorded by the history item.", "filter": "Named filter rule or rule expression applied to this site or subscription.", "filter_groups": "Ordered filter-rule group names applied to the subscription.", + "follow_current_version": "Follow the plugin's currently installed version instead of a pinned one.", "force": "Force a marketplace refresh or plugin installation when true.", "freedate": "Torrent freeleech expiration timestamp reported by the site.", "freedate_diff": "Seconds remaining until the torrent freeleech period ends.", @@ -310,13 +322,16 @@ "include_group_refs": "Include custom rules referenced only through rule groups.", "include_usage": "Include the subscriptions or defaults that reference each rule group.", "include_values": "Return complete setting values instead of discovery summaries.", + "instance_id": "Exact plugin instance ID returned by plugin.versions.get.", "is_active": "Whether the configured site is enabled.", + "is_default_target": "Whether this plugin instance is the plugin's default call target, used when a caller does not specify an instance.", "jobid": "Exact scheduler job ID returned by scheduler.list.", "key": "Optional exact plugin data key used to narrow the returned preview.", "keyword": "Case-insensitive substring used to discover settings or filter storage entries.", "labels": "Torrent labels supplied by the site result.", "lack_episode": "Number of episodes still missing from the subscription.", "last_update": "Timestamp of the subscription's most recent update.", + "level": "Target log level, one of DEBUG, INFO, WARNING, ERROR, or CRITICAL.", "library_category_folder": "Create or use a category-level folder in the target library.", "library_type_folder": "Create or use a media-type folder in the target library.", "limit_count": "Maximum number of site requests allowed in one rate-limit interval.", @@ -365,6 +380,7 @@ "person_id": "Source-native person ID returned by person search.", "pickcode": "115 storage pickcode associated with the item.", "plugin_id": "Exact installed or marketplace plugin ID.", + "plugin_version": "Exact installed plugin version; required only when not following the current version.", "poster": "Poster image URL stored with the media or subscription.", "preview": "Validate and preview manual-transfer output without committing file changes.", "pri": "Site search priority; lower or higher ordering follows the existing site API convention.", @@ -567,6 +583,8 @@ "MediaSource": "Canonical metadata source identifier paired with a source-native media ID.", "MediaType": "MoviePilot media type.", "MusicRecognizeRequest": "Exact source-native recording or album identity to resolve into canonical music metadata.", + "PluginInstanceLogLevelUpdateRequest": "One plugin instance's log-level override update request.", + "PluginInstanceVersionUpdateRequest": "One plugin instance's version-binding update request.", "PluginSourceChangeRequest": "Explicit online-source change request guarded by the current identity revision.", "PluginSourceInstallRequest": "Explicit online-source installation request for an unbound plugin.", "Site-Input": "Complete site configuration and runtime state.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 9c47b8e9d9..60c310fe51 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -1909,6 +1909,61 @@ "title": "PluginFoldersData", "type": "object" }, + "PluginInstanceLogLevelUpdateRequest": { + "description": "One plugin instance's log-level override update request.", + "properties": { + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Expiry timestamp for a plugin instance's log-level override; null means it never expires.", + "title": "Expires At" + }, + "level": { + "description": "Target log level, one of DEBUG, INFO, WARNING, ERROR, or CRITICAL.", + "title": "Level", + "type": "string" + } + }, + "required": [ + "level" + ], + "title": "PluginInstanceLogLevelUpdateRequest", + "type": "object" + }, + "PluginInstanceVersionUpdateRequest": { + "description": "One plugin instance's version-binding update request.", + "properties": { + "follow_current_version": { + "description": "Follow the plugin's currently installed version instead of a pinned one.", + "title": "Follow Current Version", + "type": "boolean" + }, + "plugin_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Exact installed plugin version; required only when not following the current version.", + "title": "Plugin Version" + } + }, + "required": [ + "follow_current_version" + ], + "title": "PluginInstanceVersionUpdateRequest", + "type": "object" + }, "PluginMarketSyncRequest": { "description": "Approved Wiki source request for plugin-market synchronization.", "properties": { @@ -8079,6 +8134,82 @@ "title": "plugin.data", "type": "object" }, + { + "additionalProperties": false, + "description": "Clear one plugin instance's default-call-target flag, only if it is the plugin's current default. Method: DELETE. Path: /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target. Effect: reversible_write.", + "properties": { + "operation_id": { + "const": "plugin.default_target.clear", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.default_target.clear. Clear one plugin instance's default-call-target flag, only if it is the plugin's current default. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.versions.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.default_target.clear", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Set one plugin instance as the plugin's default call target, automatically clearing any previous default. Method: PUT. Path: /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target. Effect: reversible_write.", + "properties": { + "operation_id": { + "const": "plugin.default_target.set", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.default_target.set. Set one plugin instance as the plugin's default call target, automatically clearing any previous default. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.versions.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.default_target.set", + "type": "object" + }, { "additionalProperties": false, "description": "Create one named plugin folder. Method: POST. Path: /api/v1/plugin/folders/{folder_name}. Effect: reversible_write.", @@ -8434,6 +8565,119 @@ "total_count_field": "collection.total_count" } }, + { + "additionalProperties": false, + "description": "Clear one plugin instance's log-level override so it immediately follows the global log level again. Method: DELETE. Path: /api/v1/plugin/loglevel/{plugin_id}/{instance_id}. Effect: reversible_write.", + "properties": { + "operation_id": { + "const": "plugin.loglevel.clear", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.loglevel.clear. Clear one plugin instance's log-level override so it immediately follows the global log level again. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.versions.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.loglevel.clear", + "type": "object" + }, + { + "additionalProperties": false, + "description": "List one plugin's instances, including its host binding, with each instance's configured and effective log level. Method: GET. Path: /api/v1/plugin/loglevel/{plugin_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.loglevel.get", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.loglevel.get. List one plugin's instances, including its host binding, with each instance's configured and effective log level. Use only the named fields below.", + "properties": { + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.loglevel.get", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Set one plugin instance's log-level override, taking effect immediately without following the global log level. Method: PUT. Path: /api/v1/plugin/loglevel/{plugin_id}/{instance_id}. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/PluginInstanceLogLevelUpdateRequest", + "description": "Request value for plugin.loglevel.set. Set one plugin instance's log-level override, taking effect immediately without following the global log level. Use the exact type and fields below." + }, + "operation_id": { + "const": "plugin.loglevel.set", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.loglevel.set. Set one plugin instance's log-level override, taking effect immediately without following the global log level. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.versions.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "plugin.loglevel.set", + "type": "object" + }, { "additionalProperties": false, "description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list and the endpoint's documented pagination or limit defaults remain in effect. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count-only request, use the smallest valid page and read collection.total_count; do not query the database merely because item data is truncated.", @@ -8949,6 +9193,113 @@ "title": "plugin.uninstall", "type": "object" }, + { + "additionalProperties": false, + "description": "List one plugin's installed source versions and each instance's version binding. Method: GET. Path: /api/v1/plugin/versions/{plugin_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.versions.get", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.versions.get. List one plugin's installed source versions and each instance's version binding. Use only the named fields below.", + "properties": { + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.versions.get", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Delete one plugin's installed source versions that are unreferenced and outside the retention window. Method: POST. Path: /api/v1/plugin/versions/{plugin_id}/recycle. Effect: external_side_effect.", + "properties": { + "operation_id": { + "const": "plugin.versions.recycle", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.versions.recycle. Delete one plugin's installed source versions that are unreferenced and outside the retention window. Use only the named fields below.", + "properties": { + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.versions.recycle", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Set one plugin instance's version binding and restart it to apply the change. Method: PUT. Path: /api/v1/plugin/versions/{plugin_id}/{instance_id}. Effect: external_side_effect.", + "properties": { + "body": { + "$ref": "#/$defs/PluginInstanceVersionUpdateRequest", + "description": "Request value for plugin.versions.set_instance. Set one plugin instance's version binding and restart it to apply the change. Use the exact type and fields below." + }, + "operation_id": { + "const": "plugin.versions.set_instance", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.versions.set_instance. Set one plugin instance's version binding and restart it to apply the change. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.versions.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "plugin.versions.set_instance", + "type": "object" + }, { "additionalProperties": false, "description": "Read personalized media or music recommendations. Method: GET. Path: /api/v1/recommend/agent. Effect: safe_read. Collection response: data remains a list and successful gateway output adds collection.result_count. collection.total_count is omitted when the endpoint or its upstream source does not expose a total.", @@ -13905,6 +14256,8 @@ "plugin.config.get", "plugin.config.update", "plugin.data", + "plugin.default_target.clear", + "plugin.default_target.set", "plugin.folder.create", "plugin.folder.delete", "plugin.folder.plugins.update", @@ -13913,6 +14266,9 @@ "plugin.history", "plugin.install", "plugin.installed", + "plugin.loglevel.clear", + "plugin.loglevel.get", + "plugin.loglevel.set", "plugin.market", "plugin.market.sync_wiki", "plugin.rating", @@ -13927,6 +14283,9 @@ "plugin.source.options", "plugin.statistics", "plugin.uninstall", + "plugin.versions.get", + "plugin.versions.recycle", + "plugin.versions.set_instance", "recommendation.list", "scheduler.list", "scheduler.progress", diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index bb41dc1f98..b16d7735b1 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -51,6 +51,7 @@ from app.application.plugin.transaction import get_plugin_persistence from app.application.scheduling import remove_plugin_job, update_plugin_job from app.runtime.extensions.plugin.contracts import PluginDashboardError, PluginNotFoundError +from app.runtime.extensions.plugin.version import resolve_instance_version_dir from app.runtime.log import logger from app.runtime.tasks import TaskRegistry from app.schemas.common import JsonObject as _SchemaJsonObject @@ -704,12 +705,11 @@ async def plugin_static_file( logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") - source_plugin_id = get_plugin_manager().get_plugin_source_id(plugin_id) - plugin_base_dir = ( - AsyncPath(get_api_runtime_config_snapshot().root_path) / "app" / "plugins" / source_plugin_id.lower() - ) + manager = get_plugin_manager() + source_plugin_id = manager.get_plugin_source_id(plugin_id) + plugin_root = get_api_runtime_config_snapshot().root_path / "app" / "plugins" / source_plugin_id.lower() + plugin_base_dir = AsyncPath(resolve_instance_version_dir(plugin_root, manager.get_plugin_instance(plugin_id))) plugin_file_path = plugin_base_dir / filepath.lstrip("/") - try: resolved_base = await plugin_base_dir.resolve() resolved_file = await plugin_file_path.resolve() diff --git a/app/api/endpoints/pluginversion.py b/app/api/endpoints/pluginversion.py new file mode 100644 index 0000000000..1a0fefd41c --- /dev/null +++ b/app/api/endpoints/pluginversion.py @@ -0,0 +1,201 @@ +"""插件已装版本查询与虚拟实例版本绑定切换接口。""" + +from typing import Any + +from fastapi import Depends, HTTPException + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.plugin.runtime import get_plugin_manager +from app.schemas.exception import PluginMutationRejectedError +from app.schemas.plugin import PluginInstanceLogLevelOverview as _SchemaPluginInstanceLogLevelOverview +from app.schemas.plugin import PluginInstanceLogLevelUpdateRequest as _SchemaPluginInstanceLogLevelUpdateRequest +from app.schemas.plugin import PluginInstanceVersionUpdateRequest as _SchemaPluginInstanceVersionUpdateRequest +from app.schemas.plugin import PluginVersionOverview as _SchemaPluginVersionOverview +from app.schemas.plugin import PluginVersionRecycleOutcome as _SchemaPluginVersionRecycleOutcome +from app.schemas.response import Response as _SchemaResponse + +router = ResponseAPIRouter() + + +@router.get( # type: ignore[misc] + "/versions/{plugin_id}", + summary="查询插件已装版本与实例版本绑定", + response_model=_SchemaResponse[_SchemaPluginVersionOverview], +) +def plugin_version_overview( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 查询插件已装版本列表与各实例的版本绑定 + """ + try: + overview = get_plugin_manager().get_plugin_version_overview(plugin_id) + except LookupError as error: + return _SchemaResponse(success=False, message=str(error)) + return _SchemaResponse(success=True, data=overview) + + +@router.put( # type: ignore[misc] + "/versions/{plugin_id}/{instance_id}", + summary="设置插件实例的版本绑定", + response_model=_SchemaResponse[None], +) +def set_plugin_instance_version( + plugin_id: str, + instance_id: str, + update: _SchemaPluginInstanceVersionUpdateRequest, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 设置指定插件实例的版本绑定,并完成一次停止再启动 + """ + plugin_manager = get_plugin_manager() + try: + overview = plugin_manager.get_plugin_version_overview(plugin_id) + except LookupError as error: + return _SchemaResponse(success=False, message=str(error)) + known_instance_ids = {item["instance_id"] for item in overview["instances"]} + if instance_id not in known_instance_ids: + return _SchemaResponse(success=False, message=f"插件实例 {instance_id} 不存在") + success, message = plugin_manager.set_plugin_instance_version( + instance_id, + follow_current_version=update.follow_current_version, + plugin_version=update.plugin_version, + ) + return _SchemaResponse( + success=success, + message="版本切换成功" if success else message, + ) + + +@router.post( # type: ignore[misc] + "/versions/{plugin_id}/recycle", + summary="回收插件不再引用的已装版本目录", + response_model=_SchemaResponse[_SchemaPluginVersionRecycleOutcome], +) +def recycle_plugin_versions( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 手动触发回收指定插件不再被引用、也不在最近版本窗口内的已装版本目录 + """ + try: + outcome = get_plugin_manager().recycle_plugin_versions(plugin_id) + except (LookupError, PluginMutationRejectedError) as error: + return _SchemaResponse(success=False, message=str(error)) + return _SchemaResponse(success=True, data=outcome) + + +@router.get( # type: ignore[misc] + "/loglevel/{plugin_id}", + summary="查询插件全部实例的日志等级设置", + response_model=_SchemaResponse[_SchemaPluginInstanceLogLevelOverview], +) +def plugin_instance_log_levels( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 查询插件全部实例(含本体)当前的日志等级设置 + """ + try: + levels = get_plugin_manager().get_plugin_instance_log_levels(plugin_id) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + return _SchemaResponse( + success=True, + data={"plugin_id": plugin_id, "instances": levels}, + ) + + +@router.put( # type: ignore[misc] + "/loglevel/{plugin_id}/{instance_id}", + summary="设置插件实例的日志等级覆盖", + response_model=_SchemaResponse[None], +) +def set_plugin_instance_log_level( + plugin_id: str, + instance_id: str, + update: _SchemaPluginInstanceLogLevelUpdateRequest, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 设置指定插件实例的日志等级覆盖,运行期立即生效,不随全局日志等级变更 + """ + try: + get_plugin_manager().set_plugin_instance_log_level( + plugin_id, instance_id, update.level, update.expires_at + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return _SchemaResponse(success=True) + + +@router.delete( # type: ignore[misc] + "/loglevel/{plugin_id}/{instance_id}", + summary="清除插件实例的日志等级覆盖", + response_model=_SchemaResponse[None], +) +def clear_plugin_instance_log_level( + plugin_id: str, + instance_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 清除指定插件实例的日志等级覆盖,立即回落全局等级,重复调用保持幂等 + """ + try: + get_plugin_manager().clear_plugin_instance_log_level(plugin_id, instance_id) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + return _SchemaResponse(success=True) + + +@router.put( # type: ignore[misc] + "/instances/{plugin_id}/{instance_id}/default_target", + summary="设置插件实例的默认调用目标", + response_model=_SchemaResponse[None], +) +def set_plugin_instance_default_target( + plugin_id: str, + instance_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 设置指定插件实例为默认调用目标,并自动清除同插件的旧默认 + """ + try: + matched = get_plugin_manager().set_plugin_instance_default_target( + plugin_id, instance_id + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + if not matched: + raise HTTPException(status_code=404, detail=f"插件实例 {instance_id} 不存在") + return _SchemaResponse(success=True) + + +@router.delete( # type: ignore[misc] + "/instances/{plugin_id}/{instance_id}/default_target", + summary="清除插件实例的默认调用目标", + response_model=_SchemaResponse[None], +) +def clear_plugin_instance_default_target( + plugin_id: str, + instance_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 清除指定插件实例的默认调用目标置位,仅当当前置位的正是该实例时才动作,重复调用保持幂等 + """ + try: + get_plugin_manager().clear_plugin_instance_default_target(plugin_id, instance_id) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + return _SchemaResponse(success=True) diff --git a/app/api/routers.py b/app/api/routers.py index ced8c46e55..a79597c2be 100644 --- a/app/api/routers.py +++ b/app/api/routers.py @@ -25,6 +25,7 @@ notification, openai, plugin, + pluginversion, recommend, rule, search, @@ -73,6 +74,7 @@ class RouterSpec(NamedTuple): RouterSpec(notification.router, "/notification", ("notification",)), RouterSpec(llm.router, "/llm", ("llm",)), RouterSpec(plugin.router, "/plugin", ("plugin",)), + RouterSpec(pluginversion.router, "/plugin", ("plugin",)), RouterSpec(download.router, "/download", ("download",)), RouterSpec(dashboard.router, "/dashboard", ("dashboard",)), RouterSpec(storage.router, "/storage", ("storage",)), diff --git a/app/db/models/__init__.py b/app/db/models/__init__.py index 39e448ae50..b5962732ce 100644 --- a/app/db/models/__init__.py +++ b/app/db/models/__init__.py @@ -21,6 +21,10 @@ "app.db.models.plugininstallation", "PluginInstallation", ), + "PluginInstance": ( + "app.db.models.plugininstance", + "PluginInstance", + ), "PluginIdentity": ( "app.db.models.pluginidentity", "PluginIdentity", diff --git a/app/db/models/plugininstance.py b/app/db/models/plugininstance.py new file mode 100644 index 0000000000..7a94faf970 --- /dev/null +++ b/app/db/models/plugininstance.py @@ -0,0 +1,58 @@ +"""共享源码插件的实例描述符持久化模型。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Boolean, CheckConstraint, Index, String, UniqueConstraint, column +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, get_id_column + + +class PluginInstance(Base): + """持久化一个共享源码插件的运行实例描述,一实例一行。 + + ``instance_id`` 既是分身的实例 ID,也可以等于 ``source_plugin_id`` 表示 + 源插件本体的版本绑定;``mode`` 用来区分这两种角色,取值 ``virtual``(分身) + 或 ``host``(本体),互不进入对方的枚举视图。 + + ``log_level`` 为空表示该实例跟随全局日志等级;非空且未过期时覆盖全局等级, + 过期判定见 ``app.runtime.log``,``log_expires_at`` 为空表示覆盖不过期。 + + ``is_default_target`` 标记该实例是否为所属源插件的默认调用目标,即外部调用 + 未指定实例时应当选中的那一行;与 ``mode``、``instance_id`` 是否等于 + ``source_plugin_id`` 都无关——本体和任意一个分身都可能被选为默认调用目标。 + 「同一源插件至多一个默认调用目标」这条不变量由 ``ux_plugininstance_default_target`` + 条件唯一索引在数据库层强制,只索引置位的行,不靠应用层纪律。 + + 表名由 ``Base`` 按类名自动派生为小写 ``plugininstance``。 + """ + + id = get_id_column() + instance_id: Mapped[str] = mapped_column(String(128), nullable=False) + source_plugin_id: Mapped[str] = mapped_column(String(128), nullable=False) + plugin_name: Mapped[Optional[str]] = mapped_column(String(255)) + plugin_desc: Mapped[Optional[str]] = mapped_column(String(255)) + plugin_icon: Mapped[Optional[str]] = mapped_column(String(255)) + mode: Mapped[str] = mapped_column(String(16), nullable=False, default="virtual") + plugin_version: Mapped[Optional[str]] = mapped_column(String(64)) + follow_current_version: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + log_level: Mapped[Optional[str]] = mapped_column(String(16)) + log_expires_at: Mapped[Optional[str]] = mapped_column(String(40)) + is_default_target: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[str] = mapped_column(String(40), nullable=False) + updated_at: Mapped[str] = mapped_column(String(40), nullable=False) + + __table_args__ = ( + UniqueConstraint("instance_id", name="uq_plugininstance_instance_id"), + Index("ix_plugininstance_source_plugin_id", "source_plugin_id"), + CheckConstraint("mode IN ('virtual', 'host')", name="ck_plugininstance_mode"), + Index( + "ux_plugininstance_default_target", + "source_plugin_id", + unique=True, + sqlite_where=column("is_default_target", Boolean).is_(True), + postgresql_where=column("is_default_target", Boolean).is_(True), + ), + ) diff --git a/app/db/oper/plugininstance.py b/app/db/oper/plugininstance.py new file mode 100644 index 0000000000..d6108c8a72 --- /dev/null +++ b/app/db/oper/plugininstance.py @@ -0,0 +1,134 @@ +"""插件实例描述符的数据访问原语。""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional, cast + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from app.db.base import DbOper +from app.db.models.plugininstance import PluginInstance + + +class PluginInstanceOper(DbOper): + """在调用方 Session 或独占事务中查询并暂存插件实例描述符。""" + + def get(self, instance_id: str) -> Optional[PluginInstance]: + """按实例 ID 查询单条描述符,分身与本体共用同一张表和同一个查询。""" + return self._execute_sync_query( + lambda session: cast( + Optional[PluginInstance], + session.execute( + select(PluginInstance).where( + PluginInstance.instance_id == instance_id + ) + ).scalars().first(), + ) + ) + + def list_by_source(self, source_plugin_id: str) -> list[PluginInstance]: + """按源插件 ID 列举其全部实例描述符,含分身与本体。""" + return list( + self._execute_sync_query( + lambda session: session.execute( + select(PluginInstance).where( + PluginInstance.source_plugin_id == source_plugin_id + ) + ).scalars() + ) + ) + + def list_all(self) -> list[PluginInstance]: + """列举全部实例描述符,供运行期批量装载和兜底导入判空使用。""" + return list( + self._execute_sync_query( + lambda session: session.execute( + select(PluginInstance) + ).scalars() + ) + ) + + def save(self, **fields: Any) -> PluginInstance: + """按 ``instance_id`` 新增或更新一条描述符。 + + :param fields: 描述符字段,须含 ``instance_id`` + :return: 写入后的描述符 + """ + now = datetime.now(timezone.utc).isoformat() + existing = self.get(fields["instance_id"]) + if existing is not None: + return self._stage_update(existing, {**fields, "updated_at": now}) + return self._stage_create( + PluginInstance(**fields, created_at=now, updated_at=now) + ) + + def delete(self, instance_id: str) -> bool: + """按实例 ID 删除描述符,返回删除前是否存在。""" + def stage(session: Session) -> bool: + """在同一事务内查询并删除,避免读写跨两个独立事务。""" + existing = session.execute( + select(PluginInstance).where( + PluginInstance.instance_id == instance_id + ) + ).scalars().first() + if existing is None: + return False + session.delete(existing) + return True + + return bool(self._execute_sync_write(stage)) + + def set_default_target(self, source_plugin_id: str, instance_id: str) -> bool: + """原子地把某源插件的默认调用目标改为指定实例,同一事务内清旧置新。 + + 目标行须已经落盘——调用方须先确保待置位的本体或分身描述符已经存在, + 这里只按 ``instance_id`` 与 ``source_plugin_id`` 双重匹配定位目标行,不做 + 隐式创建;命中失败原样返回,不动同插件原有的置位。命中时先清后置, + 两条 DML 处在同一 session、同一事务内提交,中途不会出现两行同时为真; + 并发写入下的唯一性最终由表上的条件唯一索引兜底。 + + :param source_plugin_id: 源插件 ID + :param instance_id: 要设为默认调用目标的实例 ID + :return: 目标行存在并已置位为 True,目标行不存在时为 False + """ + def stage(session: Session) -> bool: + """在同一事务内定位目标行、清除同插件其余置位、置位目标行。""" + target = session.execute( + select(PluginInstance).where( + PluginInstance.instance_id == instance_id, + PluginInstance.source_plugin_id == source_plugin_id, + ) + ).scalars().first() + if target is None: + return False + session.execute( + update(PluginInstance) + .where( + PluginInstance.source_plugin_id == source_plugin_id, + PluginInstance.instance_id != instance_id, + PluginInstance.is_default_target.is_(True), + ) + .values(is_default_target=False) + ) + target.is_default_target = True + session.add(target) + return True + + return bool(self._execute_sync_write(stage)) + + def clear_default_target(self, source_plugin_id: str) -> None: + """清除某源插件的默认调用目标置位,重复调用保持幂等。""" + def stage(session: Session) -> None: + """在同一事务内清除该源插件全部置位的行。""" + session.execute( + update(PluginInstance) + .where( + PluginInstance.source_plugin_id == source_plugin_id, + PluginInstance.is_default_target.is_(True), + ) + .values(is_default_target=False) + ) + + self._execute_sync_write(stage) diff --git a/app/runtime/compat/readiness.py b/app/runtime/compat/readiness.py new file mode 100644 index 0000000000..aeb199e51f --- /dev/null +++ b/app/runtime/compat/readiness.py @@ -0,0 +1,453 @@ +"""静态扫描插件源码,报告其对按版本分目录布局的适配情况。 + +三类判据: +1. 自引用绝对 import——插件写 ``from app.plugins.<自身pid>.xxx import X`` 引用自己包内的模块, + 版本化后真实路径变成 ``app.plugins..<版本目录>.xxx``,该写法会 ``ModuleNotFoundError``。 + 宿主不做兼容,插件必须改为相对 import。 +2. 跨插件依赖——插件引用其它插件的模块,多版本下同样脆弱,但不是本插件自身的写法错误。 +3. 共享声明基类建模——插件在宿主 ``app.db.Base``/``app.db.base.Base`` 上定义模型类, + 同一插件的两个版本会映射到同名表,第二个版本 import 时直接冲突。 + +写在 ``if TYPE_CHECKING:``(含 ``typing.TYPE_CHECKING`` 及其别名)body 内的自引用 +或跨插件绝对 import 运行期永不执行,不计入第 1、2 类判据;同一 if 的 else 分支运行期 +正常执行,仍计入判据。 + +本模块只做只读静态分析,不导入插件代码、不改变插件加载行为。 +""" + +from __future__ import annotations + +import ast +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path + +from app.runtime.compat.resources import _constant_dynamic_import, _dynamic_import_aliases + +# 插件可能用来引用宿主共享声明基类 Base 的模块路径 +_SHARED_BASE_MODULES = frozenset({"app.db", "app.db.base"}) + + +@dataclass(frozen=True, slots=True) +class SelfReferentialImportHit: + """一次自引用绝对 import 命中。""" + + file: str # 相对插件目录的文件路径 + line: int # 源码行号 + statement: str # 原始导入语句原文 + suggestion: str # 建议改写成的相对 import 写法 + + +@dataclass(frozen=True, slots=True) +class CrossPluginImportHit: + """一次跨插件 import 命中。""" + + file: str + line: int + statement: str + target_plugin_id: str # 被依赖插件的目录名 + + +@dataclass(frozen=True, slots=True) +class SharedBaseModelHit: + """一次继承宿主共享声明基类的模型类定义命中。""" + + file: str + line: int + class_name: str + + +@dataclass(frozen=True, slots=True) +class PluginVersionReadiness: + """单个插件的多版本目录布局静态扫描结论。""" + + plugin_id: str + self_referential_imports: tuple[SelfReferentialImportHit, ...] = field(default_factory=tuple) + cross_plugin_imports: tuple[CrossPluginImportHit, ...] = field(default_factory=tuple) + shared_base_models: tuple[SharedBaseModelHit, ...] = field(default_factory=tuple) + unparsed_files: tuple[str, ...] = field(default_factory=tuple) + + @property + def has_self_referential_imports(self) -> bool: + """是否命中自引用绝对 import。""" + return bool(self.self_referential_imports) + + @property + def has_cross_plugin_imports(self) -> bool: + """是否命中跨插件依赖。""" + return bool(self.cross_plugin_imports) + + @property + def has_shared_base_models(self) -> bool: + """是否命中宿主共享声明基类建模。""" + return bool(self.shared_base_models) + + @property + def is_clean(self) -> bool: + """三类判据均未命中且全部文件可解析。""" + return not ( + self.self_referential_imports + or self.cross_plugin_imports + or self.shared_base_models + or self.unparsed_files + ) + + +def _classify_plugin_module(module_name: str, own_plugin_id: str) -> tuple[str, str] | None: + """判断 module_name 是否指向某个插件包,返回 (分类, 目标插件目录名)。 + + :param module_name: 待判定的模块名 + :param own_plugin_id: 发起 import 的插件目录名 + :return: 分类为 ``self`` 表示指向 own_plugin_id 自身,``cross`` 表示指向其它 + 插件;module_name 不属于 ``app.plugins.`` 形态时返回 None + """ + parts = module_name.split(".") + if len(parts) < 3 or parts[0] != "app" or parts[1] != "plugins" or not parts[2]: + return None + target_plugin_id = parts[2] + category = "self" if target_plugin_id.lower() == own_plugin_id.lower() else "cross" + return category, target_plugin_id + + +def _relative_module_reference(from_package_parts: list[str], target_parts: list[str]) -> str: + """计算从 from_package_parts 所在包引用 target_parts 对应模块的相对写法。 + + :param from_package_parts: 发起 import 的文件所在包,相对插件根目录的目录分段 + :param target_parts: 目标模块相对插件根目录的路径分段(已剥离 app.plugins. 前缀) + :return: 形如 ``.``、``..utils``、``.sub.utils`` 的相对模块引用(不含 from/import 关键字) + """ + common = 0 + limit = min(len(from_package_parts), len(target_parts)) + while common < limit and from_package_parts[common] == target_parts[common]: + common += 1 + dots = "." * (len(from_package_parts) - common + 1) + suffix = ".".join(target_parts[common:]) + return f"{dots}{suffix}" if suffix else dots + + +def _import_from_suggestion(names: list[ast.alias], relative_ref: str) -> str: + """拼装 from-import 建议文本。""" + rendered = ", ".join( + f"{alias.name} as {alias.asname}" if alias.asname else alias.name + for alias in names + ) + return f"from {relative_ref} import {rendered}" + + +def _import_statement_suggestion( + alias: ast.alias, + from_package_parts: list[str], + target_after_pid: list[str], +) -> str: + """为 ``import app.plugins..xxx`` 形态生成建议改写文案。""" + if not target_after_pid: + return ( + "避免用 import 以绝对路径导入自身插件包;" + "如需引用包内符号,改写为 from . import <符号名>。" + ) + module_path_parts = target_after_pid[:-1] + leaf_name = target_after_pid[-1] + relative_ref = _relative_module_reference(from_package_parts, module_path_parts) + if alias.asname: + return f"from {relative_ref} import {leaf_name} as {alias.asname}" + return ( + f"from {relative_ref} import {leaf_name};" + f"并将文件内 {alias.name} 的属性访问改写为 {leaf_name}" + ) + + +def _dotted_attribute_name(expr: ast.expr) -> str | None: + """把 Name/Attribute 链还原成点分字符串,其余表达式返回 None。""" + if isinstance(expr, ast.Name): + return expr.id + if isinstance(expr, ast.Attribute): + base = _dotted_attribute_name(expr.value) + return f"{base}.{expr.attr}" if base else None + return None + + +def _collect_base_bindings(tree: ast.AST) -> tuple[set[str], set[str]]: + """收集文件内可能指向宿主共享 Base 的符号别名与模块别名。 + + ``from import [as X]`` 既可能引入符号 ``Base`` 本身,也可能 + 引入 ``_SHARED_BASE_MODULES`` 中某一模块的子模块(如 ``from app.db import + base``);后者绑定的是模块,需要按 ``.`` 拼出完整路径与 + ``_SHARED_BASE_MODULES`` 比对,才能覆盖 ``db_base.Base`` 这类属性访问写法。 + + :param tree: 已解析的文件语法树 + :return: (符号别名集合, 模块别名集合)。符号别名来自 + ``from app.db[.base] import Base [as X]``;模块别名来自 + ``import app.db[.base] [as X]``(未显式 as 时按 Python 语义绑定为 "app") + 或 ``from import [as X]`` 且 ``.`` 属于 + ``_SHARED_BASE_MODULES``(未显式 as 时绑定为 ````) + """ + symbol_aliases: set[str] = set() + module_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + for alias in node.names: + if node.module in _SHARED_BASE_MODULES and alias.name == "Base": + symbol_aliases.add(alias.asname or alias.name) + continue + if f"{node.module}.{alias.name}" in _SHARED_BASE_MODULES: + module_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name in _SHARED_BASE_MODULES: + module_aliases.add(alias.asname or alias.name.split(".")[0]) + return symbol_aliases, module_aliases + + +def _is_shared_base_reference( + expr: ast.expr, + symbol_aliases: set[str], + module_aliases: set[str], +) -> bool: + """判断一个基类表达式是否指向宿主共享声明基类 Base。""" + if isinstance(expr, ast.Name): + return expr.id in symbol_aliases + if isinstance(expr, ast.Attribute) and expr.attr == "Base": + dotted = _dotted_attribute_name(expr.value) + if dotted is None: + return False + return dotted in module_aliases or dotted in _SHARED_BASE_MODULES + return False + + +def _typing_check_aliases(tree: ast.AST) -> tuple[set[str], set[str]]: + """收集文件内 ``typing.TYPE_CHECKING`` 的符号别名与 typing 模块别名。 + + 仅类型检查分支的判定依赖这两类绑定:符号别名来自 + ``from typing import TYPE_CHECKING [as X]``,用于匹配 ``if X:``;模块别名来自 + ``import typing [as X]``,用于匹配 ``if X.TYPE_CHECKING:``。 + + :param tree: 已解析的文件语法树 + :return: (TYPE_CHECKING 符号别名集合, typing 模块别名集合) + """ + symbol_aliases: set[str] = set() + module_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "typing" and node.level == 0: + for alias in node.names: + if alias.name == "TYPE_CHECKING": + symbol_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "typing": + module_aliases.add(alias.asname or alias.name) + return symbol_aliases, module_aliases + + +def _is_type_checking_test( + test: ast.expr, + symbol_aliases: set[str], + module_aliases: set[str], +) -> bool: + """判断 if 条件表达式是否为仅类型检查判据(TYPE_CHECKING 或其别名)。""" + if isinstance(test, ast.Name): + return test.id in symbol_aliases + if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING": + base = test.value + return isinstance(base, ast.Name) and base.id in module_aliases + return False + + +def _type_checking_only_node_ids(tree: ast.AST) -> frozenset[int]: + """收集仅类型检查分支内全部节点的 id,供导入类判据跳过。 + + ``if TYPE_CHECKING:``(或其别名形式)的 body 运行期永不执行,其中的导入 + 在版本化目录下不会触发 ``ModuleNotFoundError``,不应计入阻断;同一 if 的 + ``else`` 分支运行期正常执行,不在排除范围内。 + + :param tree: 已解析的文件语法树 + :return: 需要从自引用/跨插件导入判据中排除的节点 id 集合 + """ + symbol_aliases, module_aliases = _typing_check_aliases(tree) + excluded: set[int] = set() + for node in ast.walk(tree): + if isinstance(node, ast.If) and _is_type_checking_test(node.test, symbol_aliases, module_aliases): + for stmt in node.body: + excluded.update(id(sub) for sub in ast.walk(stmt)) + return frozenset(excluded) + + +def scan_plugin_version_readiness(plugin_id: str, plugin_dir: Path) -> PluginVersionReadiness: + """扫描单个插件源码目录,返回其多版本目录布局适配结论。 + + :param plugin_id: 插件目录名(即版本化后 app/plugins//<版本号>/ 的 ) + :param plugin_dir: 插件源码目录 + :return: 结构化的静态扫描结论,语法错误等无法解析的文件记录在 unparsed_files 中,不中断扫描 + """ + self_hits: list[SelfReferentialImportHit] = [] + cross_hits: list[CrossPluginImportHit] = [] + base_hits: list[SharedBaseModelHit] = [] + unparsed: list[str] = [] + + if not plugin_dir.is_dir(): + return PluginVersionReadiness(plugin_id=plugin_id) + + for path in sorted(plugin_dir.rglob("*.py")): + if "__pycache__" in path.parts: + continue + relative_path = path.relative_to(plugin_dir) + try: + source = path.read_text(encoding="utf-8-sig") + tree = ast.parse(source, filename=str(path)) + except (OSError, SyntaxError, UnicodeError, ValueError): + unparsed.append(str(relative_path)) + continue + + from_package_parts = list(relative_path.parts[:-1]) + importlib_aliases, import_module_aliases = _dynamic_import_aliases(tree) + symbol_aliases, module_aliases = _collect_base_bindings(tree) + type_checking_only_ids = _type_checking_only_node_ids(tree) + + for node in ast.walk(tree): + if ( + isinstance(node, (ast.ImportFrom, ast.Import, ast.Call)) + and id(node) in type_checking_only_ids + ): + continue + if isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + classification = _classify_plugin_module(node.module, plugin_id) + if not classification: + continue + category, target_plugin_id = classification + statement = ast.get_source_segment(source, node) or node.module + if category == "self": + target_suffix = node.module.split(".")[3:] + relative_ref = _relative_module_reference(from_package_parts, target_suffix) + self_hits.append( + SelfReferentialImportHit( + file=str(relative_path), + line=node.lineno, + statement=statement, + suggestion=_import_from_suggestion(node.names, relative_ref), + ) + ) + else: + cross_hits.append( + CrossPluginImportHit( + file=str(relative_path), + line=node.lineno, + statement=statement, + target_plugin_id=target_plugin_id, + ) + ) + elif isinstance(node, ast.Import): + statement = ast.get_source_segment(source, node) or "" + for alias in node.names: + classification = _classify_plugin_module(alias.name, plugin_id) + if not classification: + continue + category, target_plugin_id = classification + if category == "self": + target_after_pid = alias.name.split(".")[3:] + self_hits.append( + SelfReferentialImportHit( + file=str(relative_path), + line=node.lineno, + statement=statement or alias.name, + suggestion=_import_statement_suggestion( + alias, from_package_parts, target_after_pid, + ), + ) + ) + else: + cross_hits.append( + CrossPluginImportHit( + file=str(relative_path), + line=node.lineno, + statement=statement or alias.name, + target_plugin_id=target_plugin_id, + ) + ) + elif isinstance(node, ast.Call): + module_name = _constant_dynamic_import( + node, + importlib_aliases=importlib_aliases, + import_module_aliases=import_module_aliases, + ) + if not module_name: + continue + classification = _classify_plugin_module(module_name, plugin_id) + if not classification: + continue + category, target_plugin_id = classification + statement = ast.get_source_segment(source, node) or module_name + if category == "self": + target_suffix = module_name.split(".")[3:] + if target_suffix: + relative_ref = _relative_module_reference(from_package_parts, target_suffix) + suggestion = ( + f"改为静态相对 import:from {relative_ref} import <所需符号>" + "(importlib.import_module 的绝对字符串路径在版本化目录下会失效)" + ) + else: + suggestion = ( + "避免用 importlib.import_module 以绝对路径导入自身插件包;" + "改为静态相对 import(如 from . import <符号名>)。" + ) + self_hits.append( + SelfReferentialImportHit( + file=str(relative_path), + line=node.lineno, + statement=statement, + suggestion=suggestion, + ) + ) + else: + cross_hits.append( + CrossPluginImportHit( + file=str(relative_path), + line=node.lineno, + statement=statement, + target_plugin_id=target_plugin_id, + ) + ) + elif isinstance(node, ast.ClassDef): + if any( + _is_shared_base_reference(base, symbol_aliases, module_aliases) + for base in node.bases + ): + base_hits.append( + SharedBaseModelHit( + file=str(relative_path), + line=node.lineno, + class_name=node.name, + ) + ) + + return PluginVersionReadiness( + plugin_id=plugin_id, + self_referential_imports=tuple(self_hits), + cross_plugin_imports=tuple(cross_hits), + shared_base_models=tuple(base_hits), + unparsed_files=tuple(unparsed), + ) + + +def plugin_multi_version_blockers(plugin_id: str, source_dirs: Iterable[Path]) -> list[str]: + """汇总插件多个版本源码目录中不支持多版本并存的写法。 + + 自引用绝对 import 在版本化目录下必然 ``ModuleNotFoundError``;在宿主共享声明 + 基类上定义的模型会让两个版本映射到同名表,第二个版本 import 时直接冲突。这 + 两类都是本插件自身的写法错误,会在真正双版本并存时必然失败,因此纳入阻断; + 跨插件依赖不是本插件自身的写法错误,不纳入阻断。 + + :param plugin_id: 插件目录名 + :param source_dirs: 待检查的插件源码目录,不存在的目录按无命中处理 + :return: 阻断原因列表;为空表示允许多版本并存 + """ + blockers: list[str] = [] + for source_dir in source_dirs: + readiness = scan_plugin_version_readiness(plugin_id, Path(source_dir)) + blockers.extend( + f"存在自引用绝对导入:{hit.file}:{hit.line} {hit.statement};{hit.suggestion}" + for hit in readiness.self_referential_imports + ) + blockers.extend( + f"在宿主共享声明基类上定义模型 {hit.class_name}:{hit.file}:{hit.line}" + for hit in readiness.shared_base_models + ) + return blockers diff --git a/app/runtime/event/dispatch.py b/app/runtime/event/dispatch.py index 26ae0d1013..d53e55711a 100644 --- a/app/runtime/event/dispatch.py +++ b/app/runtime/event/dispatch.py @@ -5,17 +5,27 @@ import inspect import time from collections.abc import Callable +from contextlib import AbstractContextManager, nullcontext from typing import Any from app.runtime.correlation import correlation_scope from app.runtime.event.binding import EventBindingResolver from app.runtime.event.registry import EventRegistry from app.runtime.execution import run_in_threadpool -from app.runtime.log import logger +from app.runtime.log import bind_plugin_instance, logger from app.runtime.observability import observe_duration from app.schemas.types import EventType +def _instance_binding_scope(class_name: str) -> AbstractContextManager[None]: + """处理器归属某个类时绑定其插件实例日志上下文,自由函数处理器不绑定。 + + ``class_name`` 就是声明该处理器的类的 ``__name__``;宿主类处理器同样会 + 命中这里,但缓存里没有它们的等级覆盖记录,过滤时自然回落全局等级。 + """ + return bind_plugin_instance(class_name) if class_name else nullcontext() + + class EventDispatcher: """基于订阅快照执行链式或广播事件,不拥有注册和生命周期状态。""" @@ -202,7 +212,7 @@ def invoke_sync(self, handler: Callable, event: Any) -> None: "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): method(event) except Exception as err: self._error_handler( @@ -229,7 +239,7 @@ def invoke_sync_strict( "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): method(event) except Exception as err: self._error_handler( @@ -253,7 +263,7 @@ async def invoke_async(self, handler: Callable, event: Any) -> None: "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): if inspect.iscoroutinefunction(method): await method(event) elif binding.run_sync_in_threadpool or not class_name: @@ -285,7 +295,7 @@ async def invoke_async_strict( "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): if inspect.iscoroutinefunction(method): await method(event) elif binding.run_sync_in_threadpool or not class_name: diff --git a/app/runtime/extensions/plugin/binding.py b/app/runtime/extensions/plugin/binding.py new file mode 100644 index 0000000000..e714be3e90 --- /dev/null +++ b/app/runtime/extensions/plugin/binding.py @@ -0,0 +1,306 @@ +"""插件已装版本查询与虚拟实例版本绑定切换。""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any, Optional + +from app.runtime.extensions.plugin.version import ( + plugin_version_dirs, + read_plugin_versions_manifest, + recycle_plugin_version_directories, +) +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus + +StartInstance = Callable[[str, Optional[str]], dict[str, PluginRuntimeStatus]] +MultiVersionBlockers = Callable[[str, list[Path]], list[str]] + + +class PluginVersionBinding: + """组装插件版本总览,并执行实例级版本绑定切换。""" + + def __init__( + self, + *, + plugins_root: Path, + plugin_exists: Callable[[str], bool], + get_instance: Callable[[str], Optional[PluginInstance]], + instances_for_source: Callable[[str], list[PluginInstance]], + save_instance: Callable[[PluginInstance], None], + get_host_instance: Callable[[str], Optional[PluginInstance]], + save_host_instance: Callable[[PluginInstance], None], + running: Callable[[], dict[str, Any]], + start: StartInstance, + stop: Callable[[str], None], + multi_version_blockers: MultiVersionBlockers, + log: Any, + ) -> None: + """保存版本目录、分身与本体的实例持久化端口和生命周期端口。""" + self._plugins_root = plugins_root + self._plugin_exists = plugin_exists + self._get_instance = get_instance + self._instances_for_source = instances_for_source + self._save_instance = save_instance + self._get_host_instance = get_host_instance + self._save_host_instance = save_host_instance + self._running = running + self._start = start + self._stop = stop + self._multi_version_blockers = multi_version_blockers + self._logger = log + + def _plugin_root(self, plugin_id: str) -> Path: + """定位插件源码根目录。""" + return self._plugins_root / plugin_id.lower() + + def _current_version(self, plugin_id: str) -> Optional[str]: + """读取版本元信息登记的当前版本号。""" + manifest = read_plugin_versions_manifest(self._plugin_root(plugin_id)) + current = manifest.get("current") + return current if isinstance(current, str) and current else None + + @staticmethod + def _default_host_instance(plugin_id: str) -> PluginInstance: + """本体从未被显式绑定过版本时的默认视图:跟随当前版本,未登记已生效版本。""" + return PluginInstance( + instance_id=plugin_id, + source_plugin_id=plugin_id, + mode="host", + follow_current_version=True, + ) + + def _host_instance(self, plugin_id: str) -> PluginInstance: + """读取源插件本体的版本绑定,从未绑定过时给出跟随当前版本的默认视图。""" + return self._get_host_instance(plugin_id) or self._default_host_instance(plugin_id) + + def overview(self, plugin_id: str) -> dict[str, Any]: + """组装插件已装版本列表、源插件本体与各分身实例的版本绑定。 + + 实例列表首项固定是本体自身的版本绑定,其余是引用该源码的各分身实例; + 每项都带 ``is_host`` 标记二者身份,本体从未被显式绑定过版本时按跟随 + 当前版本的默认视图呈现,而不是从列表中略去。 + + :param plugin_id: 插件ID + :return: 含已装版本列表与本体、各分身实例绑定信息的字典 + :raise LookupError: 插件不存在 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + plugin_root = self._plugin_root(plugin_id) + manifest = read_plugin_versions_manifest(plugin_root) + current_version = self._current_version(plugin_id) + registered = { + entry.get("version"): entry + for entry in (manifest.get("versions") or []) + if isinstance(entry, dict) + } + installed_versions = [ + { + "version": version, + "directory": path.name, + "installed_at": (registered.get(version) or {}).get("installed_at"), + "source": (registered.get(version) or {}).get("source"), + "is_current": version == current_version, + } + for version, path in sorted(plugin_version_dirs(plugin_root).items()) + ] + running = self._running() + host_instance = self._host_instance(plugin_id) + instances = [ + { + "instance_id": host_instance.instance_id, + "plugin_version": host_instance.plugin_version, + "follow_current_version": host_instance.follow_current_version, + "running": host_instance.instance_id in running, + "is_host": True, + "is_default_target": host_instance.is_default_target, + }, + *( + { + "instance_id": instance.instance_id, + "plugin_version": instance.plugin_version, + "follow_current_version": instance.follow_current_version, + "running": instance.instance_id in running, + "is_host": False, + "is_default_target": instance.is_default_target, + } + for instance in self._instances_for_source(plugin_id) + ), + ] + return { + "plugin_id": plugin_id, + "current_version": current_version, + "installed_versions": installed_versions, + "instances": instances, + } + + def _instance_expected_version( + self, + instance: PluginInstance, + current_version: Optional[str], + ) -> Optional[str]: + """解析实例按其绑定本应运行的版本,供并存判定使用。""" + if instance.follow_current_version: + return current_version + return instance.plugin_version or current_version + + def _creates_version_coexistence( + self, + instance: PluginInstance, + target_version: str, + ) -> bool: + """判断把指定实例切到目标版本后,该插件是否会出现多版本同时在跑。 + + 本体的期望版本同样并入候选集合:本体从未被显式绑定过版本时按跟随当前 + 版本的默认视图解析,取值与旧实现里硬编码的当前版本种子等价;本体已被 + 钉住某个版本时改按该绑定解析,不再想当然地假设本体始终运行当前版本。 + 被切换的正是本体自身时跳过这一项,因为切换后本体将处于 ``target_version``, + 计入切换前的期望版本会把自己和自己比较出一次假並存。 + """ + current_version = self._current_version(instance.source_plugin_id) + versions: set[str] = set() + if instance.instance_id != instance.source_plugin_id: + host_expected = self._instance_expected_version( + self._host_instance(instance.source_plugin_id), current_version + ) + if host_expected: + versions.add(host_expected) + for sibling in self._instances_for_source(instance.source_plugin_id): + if sibling.instance_id == instance.instance_id: + continue + sibling_version = self._instance_expected_version(sibling, current_version) + if sibling_version: + versions.add(sibling_version) + versions.add(target_version) + return len(versions) > 1 + + def set_instance_version( + self, + instance_id: str, + *, + follow_current_version: bool, + plugin_version: Optional[str] = None, + ) -> tuple[bool, str]: + """设置实例的版本绑定,并立即完成一次停止再启动。 + + 不跟随当前版本时校验目标版本已安装;如本次切换会让该插件的多个实例 + 分处不同版本,先跑多版本并存静态扫描,命中阻断原因即拒绝切换、不做 + 任何改动。切换走停止再启动的完整生命周期,不做热替换:热替换等于在 + 运行期换掉一个已注册事件、已起定时任务、可能有在途请求的实例。目标 + 版本启动失败时已生效版本保持不动,以该版本重新启动完成回退;回退同样 + 失败才判定本次切换失败,失败过程全程记录明确日志。 + + 本体与分身共用这一入口:``instance_id`` 等于某个源插件 ID 且该插件确实 + 存在时,按本体的版本绑定解析(从未绑定过时给出跟随当前版本的默认视图), + 写回时据此路由到本体或分身各自的持久化端口。 + + :param instance_id: 实例ID,可以是分身实例 ID,也可以是源插件本体自身 ID + :param follow_current_version: 是否跟随插件当前版本 + :param plugin_version: 不跟随当前版本时的目标版本号 + :return: `(是否成功, 成功时为实例ID/失败时为可读原因)` + """ + instance = self._get_instance(instance_id) + if instance is None and self._plugin_exists(instance_id): + instance = self._host_instance(instance_id) + if instance is None: + return False, f"插件实例 {instance_id} 不存在" + + target_version: Optional[str] = None + if not follow_current_version: + target_version = (plugin_version or "").strip() + if not target_version: + return False, "未跟随当前版本时必须指定目标版本" + plugin_root = self._plugin_root(instance.source_plugin_id) + installed = plugin_version_dirs(plugin_root) + if target_version not in installed: + return False, f"插件 {instance.source_plugin_id} 未安装版本 {target_version}" + if self._creates_version_coexistence(instance, target_version): + blockers = self._multi_version_blockers( + instance.source_plugin_id.lower(), list(installed.values()) + ) + if blockers: + return False, ( + f"插件 {instance.source_plugin_id} 的写法不支持多版本并存," + "拒绝切换:" + ";".join(blockers) + ) + + updated_instance = instance.model_copy( + update={"follow_current_version": follow_current_version} + ) + if instance.mode == "host": + self._save_host_instance(updated_instance) + else: + self._save_instance(updated_instance) + self._stop(instance_id) + results = self._start(instance_id, target_version) + if results.get(instance_id) == PluginRuntimeStatus.ACTIVE: + return True, instance_id + + if follow_current_version: + self._logger.error(f"插件实例 {instance_id} 切换为跟随当前版本失败") + return False, "切换为跟随当前版本失败,请查看插件日志" + + fallback_version = instance.plugin_version + if not fallback_version or fallback_version == target_version: + self._logger.error( + f"插件实例 {instance_id} 切换到版本 {target_version} 失败," + "且没有可回退的已生效版本" + ) + return False, f"切换到版本 {target_version} 失败,请查看插件日志" + + self._logger.error( + f"插件实例 {instance_id} 切换到版本 {target_version} 失败," + f"已生效版本 {fallback_version} 保持不变,正在以该版本重新启动" + ) + fallback_results = self._start(instance_id, fallback_version) + if fallback_results.get(instance_id) == PluginRuntimeStatus.ACTIVE: + return False, f"切换到版本 {target_version} 失败,已回退到原版本 {fallback_version}" + + self._logger.error( + f"插件实例 {instance_id} 以原版本 {fallback_version} 回退启动同样失败" + ) + return False, ( + f"切换到版本 {target_version} 失败,回退到原版本 {fallback_version} 同样失败" + ) + + def _referenced_versions(self, plugin_id: str) -> set[str]: + """收集本体与全部分身实例的已生效版本,以及按跟随开关解析出的期望版本。 + + 两者都要并入回收判据的引用集合,否则会误删已生效但暂无实例在跑、或 + 即将切换过去的版本;本体同样适用这条判据,遗漏本体会误删它正在用的 + 版本。集合来自对实例存储的实测查询,任何读取失败都直接向上抛出而不 + 是按空集继续,交由回收调用方跳过本次回收,避免在凑不齐引用集合的 + 情况下误删仍在用的版本且无从恢复。 + + :param plugin_id: 插件ID + :return: 被引用的版本号集合 + """ + current_version = self._current_version(plugin_id) + referenced: set[str] = set() + host_instance = self._host_instance(plugin_id) + if host_instance.plugin_version: + referenced.add(host_instance.plugin_version) + host_expected = self._instance_expected_version(host_instance, current_version) + if host_expected: + referenced.add(host_expected) + for instance in self._instances_for_source(plugin_id): + if instance.plugin_version: + referenced.add(instance.plugin_version) + expected = self._instance_expected_version(instance, current_version) + if expected: + referenced.add(expected) + return referenced + + def recycle_versions(self, plugin_id: str) -> dict[str, Any]: + """回收指定插件不再被引用、也不在最近版本窗口内的已装版本目录。 + + :param plugin_id: 插件ID + :return: 含 removed(已删除版本号列表)与 kept(版本号到保留理由的映射)的字典 + :raise LookupError: 插件不存在 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + plugin_root = self._plugin_root(plugin_id) + referenced = self._referenced_versions(plugin_id) + return recycle_plugin_version_directories(plugin_root, referenced) diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index 82c58d87ac..9f3dee7119 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -10,6 +10,7 @@ from app.runtime.extensions.plugin.contracts import supports_plugin_hook from app.runtime.extensions.plugin.storage import PluginStorage from app.runtime.extensions.plugin.system import PluginSystemServices +from app.runtime.log import get_plugin_instance_log_level_override from app.runtime.settings import get_runtime_setting from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey @@ -33,6 +34,7 @@ def __init__( plugin_attr: Callable[[str, str], Any], plugin_instance: Callable[[str], Optional[PluginInstance]], plugin_instances: Callable[[], dict[str, PluginInstance]], + host_instances: Callable[[], dict[str, PluginInstance]], runtime_status: Callable[[str], Optional[PluginRuntimeStatus]], log: Any, ) -> None: @@ -49,6 +51,7 @@ def __init__( self._plugin_attr = plugin_attr self._plugin_instance = plugin_instance self._plugin_instances = plugin_instances + self._host_instances = host_instances self._runtime_status = runtime_status self._logger = log @@ -72,6 +75,7 @@ def online(self, force: bool = False) -> list[Plugin]: def local(self) -> list[Plugin]: """把已加载插件投影为本地插件目录 DTO。""" installed = self._installed_ids() + host_instances = self._host_instances() plugins: list[Plugin] = [] for plugin_id, plugin_class in self._classes().items(): plugin_instance = self._running().get(plugin_id) @@ -95,6 +99,7 @@ def local(self) -> list[Plugin]: source_plugin_id=getattr(plugin_class, "plugin_source_id", None), is_instance=instance is not None, instance_mode=instance.mode if instance else None, + **self._instance_overlay(instance or host_instances.get(plugin_id)), ) if not self._auth_checker(plugin=plugin, source=plugin_class): continue @@ -110,6 +115,7 @@ def installed(self) -> list[Plugin]: for plugin in self.local() if plugin.installed and plugin.id } + host_instances = self._host_instances() result = [] for plugin_id in installed_ids: plugin = local_by_id.get(plugin_id) @@ -129,11 +135,39 @@ def installed(self) -> list[Plugin]: ), is_instance=instance is not None, instance_mode=instance.mode if instance else None, + **self._instance_overlay(instance or host_instances.get(plugin_id)), )) # 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。 # 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。 return result + @staticmethod + def _instance_overlay(source: Optional[PluginInstance]) -> dict[str, Any]: + """把实例或源插件本体的版本绑定记录投影为卡片列表的三个只读叠加字段。 + + ``source`` 分身来自已在遍历中取到的实例对象,本体来自批量取到的 + 本体绑定记录字典,两者读的都是已经在内存中的对象,不再另发查询。 + 没有对应记录时给出跟随全局的默认值:不钉版本、非默认目标、日志 + 等级跟随全局(返回 None)。 + + :param source: 分身实例或源插件本体的版本绑定记录,可能为 None + :return: 可直接展开进 ``Plugin(...)`` 构造参数的字段字典 + """ + if source is None: + return { + "pinned_version": None, + "is_default_target": False, + "log_level_effective": None, + } + override = get_plugin_instance_log_level_override(source.instance_id) + return { + "pinned_version": ( + None if source.follow_current_version else source.plugin_version + ), + "is_default_target": source.is_default_target, + "log_level_effective": override[0] if override is not None else None, + } + def local_version(self, plugin_id: str) -> Optional[str]: """读取指定已安装插件版本,不触发全量目录投影。""" installed = self._installed_ids() diff --git a/app/runtime/extensions/plugin/lifecycle.py b/app/runtime/extensions/plugin/lifecycle.py index 58b4f20875..cba6cd0215 100644 --- a/app/runtime/extensions/plugin/lifecycle.py +++ b/app/runtime/extensions/plugin/lifecycle.py @@ -11,6 +11,7 @@ from typing import Any, Optional, ParamSpec, TypeVar, cast from app.runtime.extensions.plugin.database import PluginDatabase +from app.runtime.log import bind_plugin_instance from app.runtime.observability import record_metric from app.schemas.plugin import PluginRuntimeStatus @@ -61,7 +62,10 @@ def __init__( *, classes: dict[str, Any], running: dict[str, Any], - load_plugins: Callable[[Optional[str], list[str], Callable[[Any], bool]], list[Any]], + load_plugins: Callable[ + [Optional[str], list[str], Callable[[Any], bool], Optional[str]], + list[Any], + ], installed_plugins: Callable[[], list[str]], plugin_config: Callable[[str], dict], auth_checker: Callable[[Any], bool], @@ -75,6 +79,7 @@ def __init__( event_sender: Callable[..., Any], refresh_classification: Callable[[str, Any], None] | None = None, remove_classification: Callable[[str], None] | None = None, + record_instance_version: Callable[[str, str], None] = lambda _id, _version: None, ) -> None: """保存注册表、加载器、数据库和事件端口。""" self._classes = classes @@ -97,6 +102,7 @@ def __init__( self._remove_classification = remove_classification or ( lambda _plugin_id: None ) + self._record_instance_version = record_instance_version self._lifecycle_lock = threading.RLock() self._quiesced_hooks: dict[str, set[str]] = {} @@ -104,8 +110,15 @@ def __init__( def start( self, plugin_id: Optional[str] = None, + *, + version: Optional[str] = None, ) -> dict[str, PluginRuntimeStatus]: - """加载并初始化插件,返回每个目标的明确运行结果。""" + """加载并初始化插件,返回每个目标的明确运行结果。 + + :param plugin_id: 插件ID,为空加载所有插件 + :param version: 虚拟实例本次显式指定加载的源码版本;仅在按单个实例 ID + 调用时生效,用于版本切换失败后以某个具体版本重试 + """ installed_plugins = self._installed_plugins() results: dict[str, PluginRuntimeStatus] = {} if plugin_id: @@ -115,7 +128,7 @@ def check_module(module: Any) -> bool: """判断模块是否具备宿主插件最小生命周期钩子。""" return hasattr(module, "init_plugin") and hasattr(module, "plugin_name") - plugins = self._load_plugins(plugin_id, installed_plugins, check_module) + plugins = self._load_plugins(plugin_id, installed_plugins, check_module, version) plugins.sort(key=lambda item: getattr(item, "plugin_order", 0)) for plugin in plugins: current_id = plugin.__name__ @@ -132,8 +145,9 @@ def check_module(module: Any) -> bool: continue self._remove_classification(current_id) self._classes[current_id] = plugin - instance = plugin() - instance.init_plugin(self._plugin_config(current_id)) + with bind_plugin_instance(current_id): + instance = plugin() + instance.init_plugin(self._plugin_config(current_id)) self._ensure_database(current_id, instance) enabled = bool(instance.get_state()) if enabled: @@ -145,6 +159,9 @@ def check_module(module: Any) -> bool: self._logger.info( f"加载插件:{current_id} 版本:{instance.plugin_version}" ) + loaded_version = getattr(instance, "plugin_version", None) + if loaded_version: + self._record_instance_version(current_id, loaded_version) if enabled: self._enable_events(plugin) else: @@ -208,7 +225,8 @@ def initialize(self, plugin_id: str, config: dict) -> None: return self._remove_classification(plugin_id) try: - plugin.init_plugin(config) + with bind_plugin_instance(plugin_id): + plugin.init_plugin(config) enabled = bool(plugin.get_state()) if enabled: self._refresh_classification_safely(plugin_id, plugin) diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index 282fe7d6e2..ff644c9f6a 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -13,13 +13,19 @@ from typing import Any, Optional from app.foundation.environment import is_free_threaded_runtime +from app.runtime.extensions.plugin.version import resolve_plugin_version_dir from app.runtime.settings import get_runtime_setting from app.schemas.plugin import PluginInstance - PluginImportPreparer = Callable[..., None] PluginImportScanner = Callable[..., None] PluginValidator = Callable[[Any], bool] +PluginHostBinding = Callable[[str], Optional[PluginInstance]] + + +def _no_host_binding(_plugin_id: str) -> Optional[PluginInstance]: + """未装配本体版本绑定端口时,视为该插件本体从未被显式绑定过版本。""" + return None class PluginLoader: @@ -34,12 +40,14 @@ def __init__( import_preparer: PluginImportPreparer, import_scanner: PluginImportScanner, log: Any, + host_binding: PluginHostBinding = _no_host_binding, ) -> None: - """保存插件目录、导入前置能力和日志端口。""" + """保存插件目录、导入前置能力、日志端口和本体版本绑定查询端口。""" self._plugins_root = plugins_root self._import_preparer = import_preparer self._import_scanner = import_scanner self._logger = log + self._host_binding = host_binding def load( self, @@ -71,12 +79,13 @@ def load( f"跳过插件目录:{plugin_dir.name}(不在加载列表中)" ) continue - if not (plugin_dir / "__init__.py").exists(): + source_dir = self._resolve_host_source_dir(plugin_dir) + if not (source_dir / "__init__.py").exists(): self._logger.debug( f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)" ) continue - if not self._is_runtime_compatible(plugin_dir): + if not self._is_runtime_compatible(source_dir): self._logger.warning( f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容" ) @@ -87,13 +96,17 @@ def load( self._logger.debug(f"正在导入插件模块:{module_name}") self._import_preparer( plugin_id=plugin_dir.name, - plugin_dir=plugin_dir, + plugin_dir=source_dir, ) self._import_scanner( plugin_id=plugin_dir.name, - plugin_dir=plugin_dir, + plugin_dir=source_dir, + ) + module = ( + importlib.import_module(module_name) + if source_dir == plugin_dir + else self._import_versioned_module(module_name, source_dir) ) - module = importlib.import_module(module_name) for name, candidate in module.__dict__.items(): if name.startswith("_") or not isinstance(candidate, type): continue @@ -110,13 +123,98 @@ def load( ) return plugins + def _resolve_host_source_dir(self, plugin_dir: Path) -> Path: + """按源插件本体的版本绑定解析待加载源码目录。 + + 语义与 ``load_instance`` 对分身绑定的三情形处理一致:本体从未显式绑定 + 过版本,或绑定为跟随当前版本时,都取插件当前版本;绑定为钉住某版本时 + 取该版本;钉住的版本目录已不在磁盘上时视为绑定已失效,记警告后回落到 + 当前版本,不让整个本体加载失败。 + + :param plugin_dir: 插件源码根目录 + :return: 源码目录 + """ + binding = self._host_binding(plugin_dir.name) + desired_version = ( + None if binding is None or binding.follow_current_version else binding.plugin_version + ) + if desired_version is None: + return resolve_plugin_version_dir(plugin_dir) + try: + return resolve_plugin_version_dir(plugin_dir, desired_version) + except ValueError as error: + self._logger.warning( + f"源插件 {plugin_dir.name} 绑定的版本目录不存在," + f"回落到插件当前版本:{error}" + ) + return resolve_plugin_version_dir(plugin_dir) + + @staticmethod + def _import_versioned_module(module_name: str, source_dir: Path) -> Any: + """按版本目录手动导入插件模块,绕开与目录名不一致的标准包解析。 + + 版本化布局下源码所在的版本目录名(如 v1_2_0)与保持不变的模块名 + (app.plugins.<插件ID>)不一致,标准 import 机制按模块名逐段定位文件会 + 找不到源码,因此改为按已解析出的源码目录直接构造模块规格。 + + :param module_name: 目标模块名 + :param source_dir: 已解析出的源码目录 + :return: 已执行完成的模块对象,模块名已在缓存中时直接返回缓存对象 + :raise ImportError: 无法为源码目录创建模块规格 + """ + cached = sys.modules.get(module_name) + if cached is not None: + return cached + source_file = source_dir / "__init__.py" + spec = importlib.util.spec_from_file_location( + module_name, + source_file, + submodule_search_locations=[str(source_dir)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"无法创建模块规格:{module_name}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: # noqa: BLE001 - 与标准 importlib 一致:执行失败必须清除半成品缓存 + sys.modules.pop(module_name, None) + raise + return module + def load_instance( self, instance: PluginInstance, validator: PluginValidator, + *, + version: Optional[str] = None, ) -> list[Any]: - """在实例专属模块命名空间中重新执行源插件代码并返回适配类。""" - source_dir = self._plugins_root / instance.source_plugin_id.lower() + """在实例专属模块命名空间中重新执行源插件代码并返回适配类。 + + 源码目录按期望版本解析:显式传入 ``version`` 时以其为准(供调用方在启动 + 失败后以某个具体版本重试);否则按实例自身绑定解析——跟随插件当前版本时 + 取清单当前版本,不跟随时取实例绑定的版本。绑定版本的目录已不在磁盘上时 + 视为该绑定已失效,记警告后回落到当前版本,而不是让整个实例加载失败。 + + :param instance: 待加载的虚拟插件实例描述 + :param validator: 候选类是否满足宿主插件契约的校验函数 + :param version: 显式指定加载的版本号,为空时按实例绑定解析 + :return: 通过校验的适配类列表;源码或运行时不兼容时为空列表 + """ + plugin_dir = self._plugins_root / instance.source_plugin_id.lower() + desired_version = ( + version + if version is not None + else (None if instance.follow_current_version else instance.plugin_version) + ) + try: + source_dir = resolve_plugin_version_dir(plugin_dir, desired_version) + except ValueError as error: + self._logger.warning( + f"虚拟插件实例 {instance.instance_id} 绑定的版本目录不存在," + f"回落到插件当前版本:{error}" + ) + source_dir = resolve_plugin_version_dir(plugin_dir) source_file = source_dir / "__init__.py" if not source_file.exists(): self._logger.warning( diff --git a/app/runtime/extensions/plugin/loglevel.py b/app/runtime/extensions/plugin/loglevel.py new file mode 100644 index 0000000000..e77083ed30 --- /dev/null +++ b/app/runtime/extensions/plugin/loglevel.py @@ -0,0 +1,135 @@ +"""插件实例日志等级查询与设置。""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any, Optional + +from app.runtime.log import ( + clear_plugin_instance_log_level, + get_effective_plugin_instance_log_level, + get_plugin_instance_log_level_override, + set_plugin_instance_log_level, +) +from app.schemas.plugin import PluginInstance + + +class PluginLogLevelControl: + """组装插件全部实例(含本体)的日志等级设置,并同步落盘与进程内缓存。""" + + def __init__( + self, + *, + plugin_exists: Callable[[str], bool], + get_instance: Callable[[str], Optional[PluginInstance]], + instances_for_source: Callable[[str], list[PluginInstance]], + save_instance: Callable[[PluginInstance], None], + get_host_instance: Callable[[str], Optional[PluginInstance]], + save_host_instance: Callable[[PluginInstance], None], + ) -> None: + """保存插件存在性判定和本体、分身实例各自的持久化端口。""" + self._plugin_exists = plugin_exists + self._get_instance = get_instance + self._instances_for_source = instances_for_source + self._save_instance = save_instance + self._get_host_instance = get_host_instance + self._save_host_instance = save_host_instance + + @staticmethod + def _default_host_instance(plugin_id: str) -> PluginInstance: + """本体从未被显式绑定过版本或日志等级时的默认视图,跟随当前版本和全局等级。""" + return PluginInstance( + instance_id=plugin_id, + source_plugin_id=plugin_id, + mode="host", + follow_current_version=True, + ) + + def _host_instance(self, plugin_id: str) -> PluginInstance: + """读取源插件本体的实例描述,从未绑定过时给出默认视图。""" + return self._get_host_instance(plugin_id) or self._default_host_instance(plugin_id) + + def _resolve(self, plugin_id: str, instance_id: str) -> PluginInstance: + """按插件 ID 和实例 ID 定位实例描述,实例须真实归属该插件。 + + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + if instance_id == plugin_id: + return self._host_instance(plugin_id) + instance = self._get_instance(instance_id) + if instance is None or instance.source_plugin_id != plugin_id: + raise LookupError(f"插件实例 {instance_id} 不存在") + return instance + + @staticmethod + def _describe(instance: PluginInstance) -> dict[str, Any]: + """把一个实例的日志等级覆盖投影为查询响应条目。""" + override = get_plugin_instance_log_level_override(instance.instance_id) + configured_level, expires_at = override if override is not None else (None, None) + return { + "instance_id": instance.instance_id, + "configured_level": configured_level, + "expires_at": expires_at, + "effective_level": get_effective_plugin_instance_log_level(instance.instance_id), + } + + def list_levels(self, plugin_id: str) -> list[dict[str, Any]]: + """列出插件全部实例(含本体)当前的日志等级设置。 + + :param plugin_id: 插件 ID + :return: 每个实例的等级设置条目列表,首项固定是本体自身 + :raise LookupError: 插件不存在 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + instances = [ + self._host_instance(plugin_id), + *self._instances_for_source(plugin_id), + ] + return [self._describe(instance) for instance in instances] + + def set_level( + self, + plugin_id: str, + instance_id: str, + level: str, + expires_at: Optional[datetime] = None, + ) -> None: + """设置指定实例的日志等级覆盖,写入进程内缓存后立即落盘。 + + :param plugin_id: 插件 ID + :param instance_id: 实例 ID + :param level: 目标日志等级 + :param expires_at: 覆盖失效时间,None 表示不过期 + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + :raise ValueError: level 不是受支持的等级名 + """ + instance = self._resolve(plugin_id, instance_id) + set_plugin_instance_log_level(instance_id, level, expires_at) + updated = instance.model_copy( + update={"log_level": level.strip().upper(), "log_expires_at": expires_at} + ) + if instance.mode == "host": + self._save_host_instance(updated) + else: + self._save_instance(updated) + + def clear_level(self, plugin_id: str, instance_id: str) -> None: + """清除指定实例的日志等级覆盖,运行期立即回落全局等级;重复清除保持幂等。 + + :param plugin_id: 插件 ID + :param instance_id: 实例 ID + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + """ + instance = self._resolve(plugin_id, instance_id) + clear_plugin_instance_log_level(instance_id) + if instance.log_level is None and instance.log_expires_at is None: + return + updated = instance.model_copy(update={"log_level": None, "log_expires_at": None}) + if instance.mode == "host": + self._save_host_instance(updated) + else: + self._save_instance(updated) diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index 20b0771f66..34a5b62f01 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -5,6 +5,7 @@ import threading import time from contextlib import contextmanager +from datetime import datetime from pathlib import Path from typing import ( Any, @@ -212,6 +213,9 @@ def __init__(self) -> None: self._plugin_sync = self._plugin_runtime.sync self._plugin_clone = self._plugin_runtime.clone self._plugin_classification = self._plugin_runtime.classification + self._plugin_version_binding = self._plugin_runtime.version_binding + self._plugin_log_level = self._plugin_runtime.log_level + self._plugin_default_target = self._plugin_runtime.default_target # 事件总线只通过通用解析器访问运行中的插件实例。 eventmanager.register_handler_instance_resolver( "plugins", @@ -1244,6 +1248,132 @@ def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str, logger.warning(str(error)) return False, str(error) + def get_plugin_version_overview(self, plugin_id: str) -> Dict[str, Any]: + """ + 查询插件已装版本列表与各实例的版本绑定 + :param plugin_id: 插件ID + :return: 含已装版本列表与各实例绑定信息的字典 + :raise LookupError: 插件不存在 + """ + return self._plugin_version_binding.overview(plugin_id) + + def set_plugin_instance_version( + self, + instance_id: str, + *, + follow_current_version: bool, + plugin_version: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + 设置虚拟插件实例的版本绑定,并完成一次停止再启动 + :param instance_id: 实例ID + :param follow_current_version: 是否跟随插件当前版本 + :param plugin_version: 不跟随当前版本时的目标版本号 + :return: (是否成功, 成功时为实例ID/失败时为可读原因) + """ + try: + with self.mutation(f"切换插件实例 {instance_id} 版本"): + return self._plugin_version_binding.set_instance_version( + instance_id, + follow_current_version=follow_current_version, + plugin_version=plugin_version, + ) + except PluginMutationRejectedError as error: + logger.warning(str(error)) + return False, str(error) + + def get_plugin_instance_log_levels(self, plugin_id: str) -> List[Dict[str, Any]]: + """ + 查询插件全部实例(含本体)当前的日志等级设置 + :param plugin_id: 插件ID + :return: 每个实例的等级设置条目列表 + :raise LookupError: 插件不存在 + """ + return self._plugin_log_level.list_levels(plugin_id) + + def set_plugin_instance_log_level( + self, + plugin_id: str, + instance_id: str, + level: str, + expires_at: Optional[datetime] = None, + ) -> None: + """ + 设置指定插件实例的日志等级覆盖,运行期立即生效 + :param plugin_id: 插件ID + :param instance_id: 实例ID + :param level: 目标日志等级 + :param expires_at: 覆盖失效时间,None 表示不过期 + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + :raise ValueError: level 不是受支持的等级名 + """ + self._plugin_log_level.set_level(plugin_id, instance_id, level, expires_at) + + def clear_plugin_instance_log_level(self, plugin_id: str, instance_id: str) -> None: + """ + 清除指定插件实例的日志等级覆盖,运行期立即回落全局等级 + :param plugin_id: 插件ID + :param instance_id: 实例ID + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + """ + self._plugin_log_level.clear_level(plugin_id, instance_id) + + def resolve_plugin_call_target(self, plugin_id: str) -> str: + """ + 确定按插件ID发起、未指定实例的调用应当落到哪个实例 + :param plugin_id: 插件ID,也可以是调用方已经明确知道的具体实例ID + :return: 应当使用的实例ID + :raise LookupError: 该插件已有分身但未设置默认调用目标,或默认调用目标已停用 + """ + return self._plugin_default_target.resolve(plugin_id) + + def set_plugin_instance_default_target(self, plugin_id: str, instance_id: str) -> bool: + """ + 设置指定插件实例为默认调用目标,并清除同插件的旧默认 + :param plugin_id: 插件ID + :param instance_id: 实例ID + :return: 目标实例存在时为True,指定的非本体实例不归属该插件时为False + :raise LookupError: 插件不存在 + """ + return self._plugin_default_target.set_target(plugin_id, instance_id) + + def clear_plugin_instance_default_target(self, plugin_id: str, instance_id: str) -> None: + """ + 清除指定插件实例的默认调用目标置位,仅当当前置位的正是该实例时才动作 + :param plugin_id: 插件ID + :param instance_id: 实例ID + :raise LookupError: 插件不存在 + """ + self._plugin_default_target.clear_target(plugin_id, instance_id) + + def recycle_plugin_versions(self, plugin_id: str) -> Dict[str, Any]: + """ + 回收指定插件不再被引用、也不在最近版本窗口内的已装版本目录 + :param plugin_id: 插件ID + :return: 含 removed 与 kept 的回收结果 + :raise LookupError: 插件不存在 + :raise PluginMutationRejectedError: 当前处于停机准入窗口,拒绝本次回收 + """ + with self.mutation(f"回收插件 {plugin_id} 已装版本"): + return self._plugin_version_binding.recycle_versions(plugin_id) + + def recycle_all_plugin_versions(self) -> Dict[str, Dict[str, Any]]: + """ + 回收全部源码插件不再被引用、也不在最近版本窗口内的已装版本目录 + 单个插件的回收失败(含引用集合收集失败、并发窗口拒绝)只记错误日志并 + 跳过该插件,不阻断其余插件的回收 + :return: 插件ID到回收结果的映射,只含成功完成本次回收的插件 + """ + results: Dict[str, Dict[str, Any]] = {} + for plugin_id in self.get_plugin_ids(): + if self.get_plugin_instance(plugin_id) is not None: + continue + try: + results[plugin_id] = self.recycle_plugin_versions(plugin_id) + except Exception as error: # noqa: BLE001 - 单个插件的回收失败不能连带阻断其余插件 + logger.error(f"插件 {plugin_id} 版本回收失败,跳过本次回收:{error}") + return results + def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: diff --git a/app/runtime/extensions/plugin/paths.py b/app/runtime/extensions/plugin/paths.py index c25532d806..e987acd2d9 100644 --- a/app/runtime/extensions/plugin/paths.py +++ b/app/runtime/extensions/plugin/paths.py @@ -8,6 +8,8 @@ from typing import Any, Optional from app.runtime.extensions.plugin.system import PluginSystemServices +from app.runtime.extensions.plugin.version import resolve_instance_version_dir +from app.schemas.plugin import PluginInstance class PluginPathResolver: @@ -20,6 +22,7 @@ def __init__( running: Callable[[], Mapping[str, Any]], system: Callable[[], PluginSystemServices], strict_system_version: Callable[[], bool], + get_instance: Callable[[str], Optional[PluginInstance]], log: Any, ) -> None: """保存运行目录和插件市场路径解析端口。""" @@ -27,6 +30,7 @@ def __init__( self._running = running self._system = system self._strict_system_version = strict_system_version + self._get_instance = get_instance self._logger = log def federated_change( @@ -71,16 +75,17 @@ def federated_change( ): return None plugin_dir = plugin_dir.resolve() - dist_dir = (plugin_dir / relative_dist_path).resolve() + version_dir = resolve_instance_version_dir(plugin_dir, self._get_instance(plugin_id)) + dist_dir = (version_dir / relative_dist_path).resolve() if ( - dist_dir == plugin_dir - or not dist_dir.is_relative_to(plugin_dir) + dist_dir == version_dir + or not dist_dir.is_relative_to(version_dir) or not event_path.is_relative_to(dist_dir) ): return None remote_entry = dist_dir / "remoteEntry.js" ready = remote_entry.is_file() and remote_entry.resolve().is_relative_to( - plugin_dir + version_dir ) return plugin_id, candidate, ready except Exception as error: diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 8b3c3405b8..0051141655 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -9,6 +9,7 @@ supports_plugin_hook, ) from app.runtime.log import logger as default_logger +from app.runtime.log import wrap_for_plugin_instance from app.schemas.plugin import PluginDashboard @@ -51,7 +52,7 @@ def commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: return commands def apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: - """聚合插件 API 并补充宿主路径和默认认证方式。""" + """聚合插件 API 并补充宿主路径和默认认证方式,端点绑定发起实例的日志上下文。""" apis: list[dict] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_api"): @@ -62,6 +63,9 @@ def apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: api["path"] = f"/{plugin_id}{api['path']}" if not api.get("auth"): api["auth"] = "apikey" + endpoint = api.get("endpoint") + if callable(endpoint): + api["endpoint"] = wrap_for_plugin_instance(endpoint, plugin_id) apis.append(api) except Exception as error: self._logger.error(f"获取插件 {plugin_id} API出错:{str(error)}") diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index 9cced93b22..45e19c5535 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -12,6 +12,7 @@ from app.runtime.events import eventmanager from app.runtime.extensions.plugin.access import PluginAccessPolicy from app.runtime.extensions.plugin.admission import PluginMutationAdmission +from app.runtime.extensions.plugin.binding import PluginVersionBinding from app.runtime.extensions.plugin.catalog import PluginCatalogFacade from app.runtime.extensions.plugin.classification import PluginClassificationRegistry from app.runtime.extensions.plugin.clone import PluginCloneService @@ -20,6 +21,7 @@ from app.runtime.extensions.plugin.dependency import PluginDependencyService from app.runtime.extensions.plugin.lifecycle import PluginLifecycle from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.loglevel import PluginLogLevelControl from app.runtime.extensions.plugin.metadata import PluginMetadataMapper from app.runtime.extensions.plugin.monitor import PluginMonitorController from app.runtime.extensions.plugin.paths import PluginPathResolver @@ -27,6 +29,7 @@ from app.runtime.extensions.plugin.registry import PluginRegistry from app.runtime.extensions.plugin.storage import ( PluginConfigStore, + PluginInstanceDirectory, PluginInstanceStore, PluginStorage, ) @@ -35,6 +38,7 @@ PluginSyncService, ) from app.runtime.extensions.plugin.system import PluginSystemServices +from app.runtime.extensions.plugin.target import PluginDefaultTargetControl from app.runtime.extensions.plugin.tools import PluginToolCatalog from app.schemas.types import SystemConfigKey @@ -81,6 +85,7 @@ async def async_get_plugins_from_market( PluginCatalogFactory = Callable[[Callable[..., Any]], Any] PluginImportService = Callable[..., None] PluginRemoteEntryBuilder = Callable[[str, str], str] +PluginMultiVersionBlockers = Callable[[str, list[Path]], list[str]] @dataclass(frozen=True, slots=True) @@ -89,6 +94,7 @@ class PluginRuntimeEnvironment: plugins_root: Path storage: Callable[[], PluginStorage] + instance_directory: Callable[[], PluginInstanceDirectory] system: Callable[[], PluginSystemServices] database: Callable[[], PluginDatabase] catalog_factory: PluginCatalogFactory @@ -98,6 +104,9 @@ class PluginRuntimeEnvironment: remote_entry: PluginRemoteEntryBuilder development: Callable[[], bool] logger: Any + multi_version_blockers: PluginMultiVersionBlockers + set_default_target: Callable[[str, str], bool] + clear_default_target: Callable[[str], None] @dataclass(frozen=True, slots=True) @@ -120,6 +129,9 @@ class PluginRuntime: metadata: PluginMetadataMapper sync: PluginSyncService clone: PluginCloneService + version_binding: PluginVersionBinding + log_level: PluginLogLevelControl + default_target: PluginDefaultTargetControl projection: PluginProjection classification: PluginClassificationRegistry recent_local_sync: dict[str, float] @@ -134,7 +146,10 @@ def build_plugin_runtime( ) -> PluginRuntime: """按依赖顺序构造唯一插件运行时,各业务能力仍由对应 owner 实现。""" registry = PluginRegistry() - instances = PluginInstanceStore(storage=environment.storage) + instances = PluginInstanceStore( + storage=environment.storage, + directory=environment.instance_directory, + ) configs = PluginConfigStore( storage=environment.storage, database=environment.database, @@ -150,6 +165,7 @@ def build_plugin_runtime( import_preparer=environment.import_preparer, import_scanner=environment.import_scanner, log=environment.logger, + host_binding=instances.get_host, ) tools = PluginToolCatalog(max_attempts=tool_build_max_attempts) classification = PluginClassificationRegistry(environment.logger) @@ -168,12 +184,18 @@ def load_plugins( plugin_id: Optional[str], installed_plugins: list[str], validator: Callable[[Any], bool], + version: Optional[str] = None, ) -> list[Any]: - """加载物理插件或虚拟实例,并保持持久化实例顺序。""" + """加载物理插件或虚拟实例,并保持持久化实例顺序。 + + ``version`` 仅在按单个实例 ID 加载时生效,用于版本切换失败后以某个 + 具体版本重试;批量加载全部安装插件与实例时忽略该参数,各实例按自身 + 绑定解析期望版本。 + """ if plugin_id: instance = instances.get(plugin_id) if instance: - return loader.load_instance(instance, validator) + return loader.load_instance(instance, validator, version=version) return loader.load(plugin_id, installed_plugins, validator) plugins = loader.load(None, installed_plugins, validator) for instance in instances.all().values(): @@ -199,6 +221,7 @@ def load_plugins( event_sender=eventmanager.send_event, refresh_classification=refresh_classification, remove_classification=classification.remove, + record_instance_version=instances.record_effective_version, ) metadata = PluginMetadataMapper( plugin_instance=registry.instance, @@ -248,6 +271,7 @@ def load_plugins( ), plugin_instance=instances.get, plugin_instances=instances.all, + host_instances=instances.all_hosts, runtime_status=registry.runtime_status, log=environment.logger, ) @@ -256,6 +280,7 @@ def load_plugins( running=lambda: registry.running, system=environment.system, strict_system_version=lambda: not environment.development(), + get_instance=instances.get, log=environment.logger, ) recent_local_sync: dict[str, float] = {} @@ -313,6 +338,38 @@ def source_plugin_id(plugin_id: str) -> str: remove_plugin=host.remove_plugin, log=environment.logger, ) + version_binding = PluginVersionBinding( + plugins_root=environment.plugins_root, + plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None, + get_instance=instances.get, + instances_for_source=instances.for_source, + save_instance=instances.save, + get_host_instance=instances.get_host, + save_host_instance=instances.save_host, + running=lambda: registry.running, + start=lambda instance_id, version: lifecycle.start(instance_id, version=version), + stop=lifecycle.stop, + multi_version_blockers=environment.multi_version_blockers, + log=environment.logger, + ) + log_level = PluginLogLevelControl( + plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None, + get_instance=instances.get, + instances_for_source=instances.for_source, + save_instance=instances.save, + get_host_instance=instances.get_host, + save_host_instance=instances.save_host, + ) + default_target = PluginDefaultTargetControl( + plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None, + get_instance=instances.get, + instances_for_source=instances.for_source, + get_host_instance=instances.get_host, + save_host_instance=instances.save_host, + running=lambda: registry.running, + set_default_target=environment.set_default_target, + clear_default_target=environment.clear_default_target, + ) projection = PluginProjection( registry.running, environment.logger, @@ -338,6 +395,9 @@ def source_plugin_id(plugin_id: str) -> str: metadata=metadata, sync=sync, clone=clone, + version_binding=version_binding, + log_level=log_level, + default_target=default_target, projection=projection, classification=classification, recent_local_sync=recent_local_sync, diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index 94f66ec741..2f23875c1d 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -148,15 +148,137 @@ def delete_data(self, plugin_id: str, force: bool = False) -> bool: return True +InstanceReader = Callable[[str], "PluginInstance | None"] +InstanceLister = Callable[[], "list[PluginInstance]"] +InstanceSourceLister = Callable[[str], "list[PluginInstance]"] +InstanceWriter = Callable[["PluginInstance"], None] +InstanceDeleter = Callable[[str], bool] + + +def _empty_instance_get(_instance_id: str) -> PluginInstance | None: + """组合根尚未装配时返回空实例描述。""" + return None + + +def _empty_instance_list() -> list[PluginInstance]: + """组合根尚未装配时返回空实例列表。""" + return [] + + +def _empty_instance_list_by_source(_source_plugin_id: str) -> list[PluginInstance]: + """组合根尚未装配时返回空实例列表。""" + return [] + + +def _ignore_instance_save(_instance: PluginInstance) -> None: + """组合根尚未装配时忽略实例描述写入。""" + + +def _ignore_instance_delete(_instance_id: str) -> bool: + """组合根尚未装配时报告实例描述未删除。""" + return False + + +class PluginInstanceDirectory: + """封装插件实例描述符独立表的持久化能力。 + + 分身与源插件本体的版本绑定共用同一张表、同一套读写原语,两者只靠各自 + ``PluginInstance.mode`` 取值区分;本类不做角色过滤,角色隔离由调用方 + (``PluginInstanceStore``)负责,因为只有调用方知道当前是在服务分身清单 + 还是本体绑定这两类完全不同的语义。 + """ + + def __init__( + self, + *, + get: InstanceReader = _empty_instance_get, + list_all: InstanceLister = _empty_instance_list, + list_by_source: InstanceSourceLister = _empty_instance_list_by_source, + save: InstanceWriter = _ignore_instance_save, + delete: InstanceDeleter = _ignore_instance_delete, + ) -> None: + """保存由启动组合根提供的实例描述符表读写函数。""" + self._get = get + self._list_all = list_all + self._list_by_source = list_by_source + self._save = save + self._delete = delete + + def get(self, instance_id: str) -> PluginInstance | None: + """按实例 ID 读取单条描述,不区分分身与本体。""" + return self._get(instance_id) + + def list_all(self) -> list[PluginInstance]: + """列出表中全部描述,不区分分身与本体。""" + return self._list_all() + + def list_by_source(self, source_plugin_id: str) -> list[PluginInstance]: + """按源插件 ID 列出其全部描述,不区分分身与本体。""" + return self._list_by_source(source_plugin_id) + + def save(self, instance: PluginInstance) -> None: + """新增或更新一条描述,以 ``instance_id`` 为稳定键。""" + self._save(instance) + + def delete(self, instance_id: str) -> bool: + """按实例 ID 删除一条描述,返回删除前是否存在。""" + return self._delete(instance_id) + + +_plugin_instance_directory = PluginInstanceDirectory() + + +def configure_plugin_instance_directory(directory: PluginInstanceDirectory) -> None: + """由启动组合根替换插件实例描述符表持久化实现。""" + global _plugin_instance_directory + _plugin_instance_directory = directory + + +def get_plugin_instance_directory() -> PluginInstanceDirectory: + """返回当前插件实例描述符表持久化端口。""" + return _plugin_instance_directory + + class PluginInstanceStore: - """管理虚拟插件实例描述,并隔离兼容清单与新实例清单。""" + """管理虚拟插件实例描述与源插件本体的版本绑定,二者互不进入对方视图。 - def __init__(self, *, storage: Callable[[], "PluginStorage"]) -> None: - """保存延迟解析的持久化端口,便于启动组合根后装配。""" - self._storage = storage + 两类记录同存一张独立表,只靠 ``mode`` 字段区分:``all()``/``get()``/ + ``save()``/``delete()``/``for_source()`` 只服务分身,是这些方法迁移前 + 的既有合同;``get_host()``/``save_host()`` 是本任务新增的本体访问入口, + 只服务本体。任何一侧都读不到、也删不到对方的记录。 + """ - def all(self) -> dict[str, PluginInstance]: - """读取全部有效实例,忽略损坏项以免阻断存量插件启动。""" + def __init__( + self, + *, + storage: Callable[[], "PluginStorage"], + directory: Callable[[], PluginInstanceDirectory], + ) -> None: + """保存独立表持久化端口,以及旧 systemconfig 单键端口供兜底导入使用。""" + self._storage = storage + self._directory = directory + self._bootstrap_checked = False + + def _ensure_bootstrapped(self) -> None: + """新表为空而旧 systemconfig 单键非空时,把旧内容原样导入表一次。 + + 触发条件是「表当前为空」这一实测事实,不是某个一次性开关:导入完成后 + 表不再为空,同一份数据不会被重复导入;进程内额外维护一个已检查标志, + 避免每次访问都为判空多打一次查询。 + + :raise Exception: 导入失败时向上抛出,不吞掉持久化层错误 + """ + if self._bootstrap_checked: + return + self._bootstrap_checked = True + directory = self._directory() + if directory.list_all(): + return + for instance in self._legacy_instances().values(): + directory.save(instance) + + def _legacy_instances(self) -> dict[str, PluginInstance]: + """解析旧 systemconfig 单键里的实例描述,兼容历史字典与列表两种载荷形态。""" raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {} if isinstance(raw_instances, list): entries = { @@ -180,40 +302,88 @@ def all(self) -> dict[str, PluginInstance]: continue return instances + def all(self) -> dict[str, PluginInstance]: + """读取全部有效分身实例,不含源插件本体的版本绑定记录。""" + self._ensure_bootstrapped() + return { + record.instance_id: record + for record in self._directory().list_all() + if record.mode == "virtual" + } + def get(self, instance_id: str) -> PluginInstance | None: - """读取指定实例描述。""" - return self.all().get(instance_id) + """读取指定分身实例描述,本体的版本绑定记录不会从这里返回。""" + self._ensure_bootstrapped() + record = self._directory().get(instance_id) + return record if record is not None and record.mode == "virtual" else None + + def all_hosts(self) -> dict[str, PluginInstance]: + """一次性读取全部源插件本体的版本绑定记录,不含分身实例。 + + 供目录投影批量取数使用,按插件 ID 遍历卡片时只做内存字典查找, + 不再逐张卡片各查一次数据库。 + """ + self._ensure_bootstrapped() + return { + record.instance_id: record + for record in self._directory().list_all() + if record.mode == "host" + } def save(self, instance: PluginInstance) -> None: - """新增或更新实例描述,并以实例 ID 作为稳定持久化键。""" - instances = self.all() - instances[instance.instance_id] = instance - self._write(instances) + """新增或更新分身实例描述,并以实例 ID 作为稳定持久化键。""" + self._ensure_bootstrapped() + self._directory().save(instance.model_copy(update={"mode": "virtual"})) def delete(self, instance_id: str) -> bool: - """删除指定实例描述,返回删除前是否存在。""" - instances = self.all() - removed = instances.pop(instance_id, None) - if removed is None: + """删除指定分身实例描述,返回删除前是否存在。""" + self._ensure_bootstrapped() + if self.get(instance_id) is None: return False - self._write(instances) - return True + return self._directory().delete(instance_id) def for_source(self, source_plugin_id: str) -> list[PluginInstance]: - """按持久化顺序返回引用同一源码插件的全部实例。""" + """按持久化顺序返回引用同一源插件的全部分身实例,不含本体绑定记录。""" + self._ensure_bootstrapped() return [ - instance - for instance in self.all().values() - if instance.source_plugin_id == source_plugin_id + record + for record in self._directory().list_by_source(source_plugin_id) + if record.mode == "virtual" ] - def _write(self, instances: dict[str, PluginInstance]) -> None: - """把模型映射序列化为普通字典,避免存储层依赖 Pydantic。""" - payload = { - instance_id: instance.model_dump(mode="json") - for instance_id, instance in instances.items() - } - self._storage().write(SystemConfigKey.PluginInstances, payload) + def get_host(self, plugin_id: str) -> PluginInstance | None: + """读取源插件本体的版本绑定记录;从未显式绑定过版本时为 None。""" + self._ensure_bootstrapped() + record = self._directory().get(plugin_id) + return record if record is not None and record.mode == "host" else None + + def save_host(self, instance: PluginInstance) -> None: + """新增或更新源插件本体的版本绑定记录,本体的 ``instance_id`` 恒等于其自身 ID。""" + self._ensure_bootstrapped() + self._directory().save( + instance.model_copy( + update={"mode": "host", "source_plugin_id": instance.instance_id} + ) + ) + + def record_effective_version(self, instance_id: str, version: str) -> None: + """把本次成功启动所用的版本登记为分身或本体的已生效版本。 + + 分身尚未创建、本体从未被显式绑定过版本时都读取为空,此时静默跳过而不 + 是隐式创建一条记录,避免每个物理插件的每次成功启动都触发一次持久化 + 写入;值未变化时同样不产生写入。 + + :param instance_id: 实例 ID,也可能是分身与本体都未持有记录的物理插件 ID + :param version: 本次成功加载的源码所声明的版本号 + """ + instance = self.get(instance_id) + if instance is not None: + if instance.plugin_version != version: + self.save(instance.model_copy(update={"plugin_version": version})) + return + host_instance = self.get_host(instance_id) + if host_instance is not None and host_instance.plugin_version != version: + self.save_host(host_instance.model_copy(update={"plugin_version": version})) _plugin_storage = PluginStorage() diff --git a/app/runtime/extensions/plugin/target.py b/app/runtime/extensions/plugin/target.py new file mode 100644 index 0000000000..78c21671ea --- /dev/null +++ b/app/runtime/extensions/plugin/target.py @@ -0,0 +1,183 @@ +"""插件默认调用目标裁决:未指定实例的调用选择实例,以及默认目标的置位与清除。""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, Optional + +from app.schemas.plugin import PluginInstance + +GetInstance = Callable[[str], Optional[PluginInstance]] +InstancesForSource = Callable[[str], list[PluginInstance]] +PluginExists = Callable[[str], bool] +RunningInstances = Callable[[], Mapping[str, Any]] +AtomicSetDefaultTarget = Callable[[str, str], bool] +ClearDefaultTarget = Callable[[str], None] + + +@dataclass(frozen=True) +class PluginCallCandidate: + """一个插件实例在默认调用目标裁决中的可见状态。 + + ``is_default_target`` 是用户选定的默认调用目标,与该实例当前是否在运行 + 无关;``is_running`` 是该实例当前是否在运行,与调用目标的选定无关。 + """ + + instance_id: str + is_running: bool + is_default_target: bool + + +def _ordered(candidates: list[PluginCallCandidate]) -> list[PluginCallCandidate]: + """把候选实例按实例 ID 升序排列,使报错文案稳定可预期。""" + return sorted(candidates, key=lambda candidate: candidate.instance_id) + + +def _describe(candidates: list[PluginCallCandidate]) -> str: + """列出可供显式指定的实例名及其运行状态。 + + :param candidates: 候选实例集合 + :return: 形如 ``PluginA(已启用)、PluginAx2(已停用)`` 的描述,候选为空时为「无」 + """ + if not candidates: + return "无" + return "、".join( + f"{candidate.instance_id}({'已启用' if candidate.is_running else '已停用'})" + for candidate in _ordered(candidates) + ) + + +class PluginDefaultTargetControl: + """裁决插件未指定实例时的调用目标,并管理默认调用目标的置位与清除。 + + 一个插件按配置扇出多个实例后,「调用没指定实例」只允许两种结局:走用户 + 选定且正在运行的默认调用目标,或者报错。绝不按登记顺序取第一个,也绝不 + 在默认目标停用时静默改走另一个正在运行的实例——那等于用户停用了一个实例、 + 调用却被悄悄改道,且不留任何痕迹。只有本体、没有任何分身的插件不受这套 + 机制约束,直接使用本体,不要求显式设置默认目标,单实例场景不应被打扰。 + """ + + def __init__( + self, + *, + plugin_exists: PluginExists, + get_instance: GetInstance, + instances_for_source: InstancesForSource, + get_host_instance: GetInstance, + save_host_instance: Callable[[PluginInstance], None], + running: RunningInstances, + set_default_target: AtomicSetDefaultTarget, + clear_default_target: ClearDefaultTarget, + ) -> None: + """保存本体与分身的实例持久化端口、运行态端口和默认目标置位的原子写入端口。""" + self._plugin_exists = plugin_exists + self._get_instance = get_instance + self._instances_for_source = instances_for_source + self._get_host_instance = get_host_instance + self._save_host_instance = save_host_instance + self._running = running + self._set_default_target = set_default_target + self._clear_default_target = clear_default_target + + @staticmethod + def _default_host_instance(plugin_id: str) -> PluginInstance: + """本体从未被显式绑定过版本、日志等级或默认目标时的默认视图。""" + return PluginInstance( + instance_id=plugin_id, + source_plugin_id=plugin_id, + mode="host", + follow_current_version=True, + ) + + def _host_instance(self, plugin_id: str) -> PluginInstance: + """读取源插件本体的实例描述,从未绑定过时给出默认视图。""" + return self._get_host_instance(plugin_id) or self._default_host_instance(plugin_id) + + def _candidates(self, plugin_id: str) -> list[PluginCallCandidate]: + """组装插件全部实例(含本体)在调用目标裁决中的可见状态。""" + running = self._running() + instances = [self._host_instance(plugin_id), *self._instances_for_source(plugin_id)] + return [ + PluginCallCandidate( + instance_id=instance.instance_id, + is_running=instance.instance_id in running, + is_default_target=instance.is_default_target, + ) + for instance in instances + ] + + def resolve(self, plugin_id: str) -> str: + """确定按插件 ID 发起、未指定实例的调用应当落到哪个实例。 + + 插件从未被创建过分身时直接返回插件 ID 本身(即本体),不查默认目标 + 置位——这也覆盖了调用方直接传入某个分身自身实例 ID 的情形:分身的 + 实例 ID 不会作为任何插件的源插件 ID 拥有分身,因而同样原样返回。 + 已有分身时必须命中已设置且正在运行的默认调用目标才会被采用。 + + :param plugin_id: 插件 ID,也可以是调用方已经明确知道的具体实例 ID + :return: 应当使用的实例 ID + :raise LookupError: 已有分身但未设置默认调用目标,或默认调用目标已停用 + """ + if not self._instances_for_source(plugin_id): + return plugin_id + + candidates = self._candidates(plugin_id) + default = next( + (candidate for candidate in candidates if candidate.is_default_target), None + ) + if default is not None and default.is_running: + return default.instance_id + + candidate_desc = _describe(candidates) + if default is not None: + raise LookupError( + f"插件 {plugin_id} 的默认实例 {default.instance_id} 已停用," + f"调用必须显式指定实例;可选实例:{candidate_desc}" + ) + raise LookupError( + f"插件 {plugin_id} 未设置默认实例,调用必须显式指定实例;可选实例:{candidate_desc}" + ) + + def set_target(self, plugin_id: str, instance_id: str) -> bool: + """把插件的默认调用目标改为指定实例,同一事务内清除同插件的旧置位。 + + ``instance_id`` 等于插件 ID 时视为把本体设为默认目标;本体此前从未被 + 显式绑定过任何设置时,先落盘一条默认视图的本体记录,确保随后的数据库 + 级清旧置新有行可操作——这与版本切换、日志等级两处对本体的写入语义一致。 + + :param plugin_id: 插件 ID + :param instance_id: 要设为默认调用目标的实例 ID + :return: 目标实例存在时为 True;指定的非本体实例不归属该插件时为 False + :raise LookupError: 插件不存在 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + if instance_id == plugin_id: + if self._get_host_instance(plugin_id) is None: + self._save_host_instance(self._default_host_instance(plugin_id)) + else: + instance = self._get_instance(instance_id) + if instance is None or instance.source_plugin_id != plugin_id: + return False + return self._set_default_target(plugin_id, instance_id) + + def clear_target(self, plugin_id: str, instance_id: str) -> None: + """清除插件的默认调用目标置位,仅当当前置位的正是指定实例时才动作。 + + 请求清除的实例并非当前置位(含插件当前没有任何置位)时按空操作处理, + 这是清除接口的幂等语义,不是「找不到就报错」。 + + :param plugin_id: 插件 ID + :param instance_id: 请求清除默认调用目标的实例 ID + :raise LookupError: 插件不存在 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + current = next( + (candidate for candidate in self._candidates(plugin_id) if candidate.is_default_target), + None, + ) + if current is None or current.instance_id != instance_id: + return + self._clear_default_target(plugin_id) diff --git a/app/runtime/extensions/plugin/version.py b/app/runtime/extensions/plugin/version.py new file mode 100644 index 0000000000..ad4afbdda0 --- /dev/null +++ b/app/runtime/extensions/plugin/version.py @@ -0,0 +1,585 @@ +"""插件源码按版本分目录布局的目录名映射、元信息读写、存量迁移、加载路径解析与回收。""" + +from __future__ import annotations + +import ast +import errno +import json +import os +import re +import shutil +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from app.foundation.version import compare_version +from app.runtime.log import logger +from app.schemas.plugin import PluginInstance + +# 插件源码版本目录名的前缀,用于把版本目录与插件目录下的其它条目区分开 +PLUGIN_VERSION_DIR_PREFIX = "v" +# 插件已装版本元信息文件名,位于 app/plugins/<插件ID>/ 下,不是 Python 模块 +PLUGIN_VERSIONS_MANIFEST_NAME = "versions.json" +# 版本元信息文件的结构版本号 +PLUGIN_VERSIONS_MANIFEST_SCHEMA = 1 +# 存量布局迁移过程中的改名中转目录名中缀,与插件源码目录同级 +_PLUGIN_LAYOUT_STAGING_INFIX = ".migrating-" +# 存量插件读不到版本号时使用的兜底版本号 +PLUGIN_FALLBACK_VERSION = "0.0.0" +# 合法版本号字符集:数字、字母、点、连字符、加号。语义化版本的先行版与构建 +# 元数据字符集不含下划线,据此保证点与下划线的互换是单射、可逆 +_PLUGIN_VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+-]*$") +# 插件版本回收默认按登记时间额外保留的最近版本数,含当前版本。1 起不到「留 +# 退路」的作用;4 个以上在磁盘占用与回退冗余之间收益递减,2 是满足「装错新 +# 版本后一键切回上一版」这一典型场景的最小值 +PLUGIN_VERSION_RETENTION_WINDOW = 2 + + +def plugin_version_dir_name(version: str) -> str: + """把插件版本号映射为版本目录名。 + + 映射规则为前缀 ``v`` 加上版本号中的 ``.`` 全部换成 ``_``,例如 ``1.2.0`` + 映射为 ``v1_2_0``。版本号含下划线时直接拒绝,不做静默转换,否则两个不同 + 版本号会映射到同一个目录。 + + :param version: 插件版本号 + :return: 版本目录名 + :raise ValueError: 版本号为空、含下划线,或含版本号字符集以外的字符 + """ + text = (version or "").strip() + if not text: + raise ValueError("插件版本号为空") + if "_" in text: + raise ValueError(f"插件版本号含下划线,无法映射为版本目录:{version}") + if not _PLUGIN_VERSION_PATTERN.match(text) or ".." in text or text.endswith("."): + raise ValueError(f"插件版本号不是语义化版本:{version}") + return f"{PLUGIN_VERSION_DIR_PREFIX}{text.replace('.', '_')}" + + +def plugin_version_from_dir_name(dir_name: str) -> str | None: + """把版本目录名反解为插件版本号。 + + 反解规则是去掉前导 ``v`` 后把 ``_`` 换回 ``.``。反解结果需能原样映射回原 + 目录名,否则视为不是版本目录,据此排除 dist、wheels、__pycache__ 等目录。 + + :param dir_name: 目录名 + :return: 版本号;不是版本目录时为 None + """ + if not dir_name or not dir_name.startswith(PLUGIN_VERSION_DIR_PREFIX): + return None + core = dir_name[len(PLUGIN_VERSION_DIR_PREFIX):] + if not core or "." in core: + return None + version = core.replace("_", ".") + try: + if plugin_version_dir_name(version) != dir_name: + return None + except ValueError: + return None + return version + + +def plugin_version_dirs(plugin_root: Path) -> dict[str, Path]: + """列出插件源码目录下所有版本目录。 + + :param plugin_root: 插件源码根目录(app/plugins/<插件ID>) + :return: 版本号到版本目录的映射,目录不存在时为空字典 + """ + result: dict[str, Path] = {} + try: + entries = sorted(plugin_root.iterdir()) + except (FileNotFoundError, NotADirectoryError, OSError): + return result + for entry in entries: + if not entry.is_dir(): + continue + version = plugin_version_from_dir_name(entry.name) + if version: + result[version] = entry + return result + + +def read_plugin_versions_manifest(plugin_root: Path) -> dict[str, Any]: + """读取插件已装版本元信息。 + + :param plugin_root: 插件源码根目录 + :return: 元信息字典,文件缺失、损坏或格式不是字典时为空字典 + """ + manifest_file = plugin_root / PLUGIN_VERSIONS_MANIFEST_NAME + try: + payload = json.loads(manifest_file.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, ValueError) as err: + logger.warning(f"插件版本元信息不可读,按未登记处理:{manifest_file} - {err}") + return {} + return payload if isinstance(payload, dict) else {} + + +def write_plugin_versions_manifest( + plugin_root: Path, + versions: list[dict[str, Any]], + current: str | None, +) -> None: + """写入插件已装版本元信息。 + + :param plugin_root: 插件源码根目录 + :param versions: 版本条目列表,每条含 version、directory、installed_at、source + :param current: 当前生效版本号 + """ + payload = { + "schema_version": PLUGIN_VERSIONS_MANIFEST_SCHEMA, + "plugin_id": plugin_root.name, + "current": current, + "versions": versions, + } + plugin_root.mkdir(parents=True, exist_ok=True) + (plugin_root / PLUGIN_VERSIONS_MANIFEST_NAME).write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def plugin_manifest_versions(plugin_root: Path) -> dict[str, str]: + """读取元信息登记的版本号到目录名映射,并校验目录名可由版本号推出。 + + 目录名不是权威真值,权威在元信息的版本号。两者不一致时告警并以元信息为准。 + + :param plugin_root: 插件源码根目录 + :return: 版本号到目录名的映射 + """ + result: dict[str, str] = {} + for entry in read_plugin_versions_manifest(plugin_root).get("versions") or []: + if not isinstance(entry, dict): + continue + version = entry.get("version") + directory = entry.get("directory") + if not isinstance(version, str) or not version: + continue + try: + expected = plugin_version_dir_name(version) + except ValueError as err: + logger.warning(f"插件 {plugin_root.name} 元信息版本号非法,已忽略:{err}") + continue + if isinstance(directory, str) and directory and directory != expected: + logger.warning( + f"插件 {plugin_root.name} 版本 {version} 的目录名 {directory} " + f"与元信息不一致,以元信息为准使用 {expected}" + ) + result[version] = expected + return result + + +def ensure_plugin_version_dir_available(plugin_root: Path, version: str) -> str: + """校验版本号可安装并返回其版本目录名。 + + 除版本号字符集校验外,还对同插件已装版本做大小写不敏感比对,避免在大小写 + 不敏感的文件系统上两个版本落到同一个目录。 + + :param plugin_root: 插件源码根目录 + :param version: 待安装版本号 + :return: 版本目录名 + :raise ValueError: 版本号非法,或与已装版本大小写撞名 + """ + dir_name = plugin_version_dir_name(version) + known: dict[str, str] = dict(plugin_manifest_versions(plugin_root)) + known.update( + {installed: path.name for installed, path in plugin_version_dirs(plugin_root).items()} + ) + for installed_version, installed_dir in known.items(): + if installed_version == version: + continue + if installed_dir.lower() == dir_name.lower(): + raise ValueError( + f"插件版本 {version} 与已装版本 {installed_version} 的目录名仅大小写不同,拒绝安装" + ) + return dir_name + + +def register_plugin_version( + plugin_root: Path, version: str, source: str +) -> tuple[str, str | None]: + """把一个已就位的版本目录登记进版本元信息,并置为当前版本。 + + 调用方需确保 ``plugin_root / <版本目录>`` 已经就位了该版本的源码;本函数 + 只更新元信息,不做任何文件搬迁,因此可以安全地被存量迁移和真正的多版本 + 安装共用。同时返回登记前的当前版本号,供安装失败清理据此精确复原当前 + 版本,不必在回滚时靠猜。 + + :param plugin_root: 插件源码根目录 + :param version: 版本号 + :param source: 版本来源,如 local、migrated + :return: 版本目录名,以及登记前元信息里的当前版本号(插件在本次登记前 + 没有任何已装版本时为 None) + :raise ValueError: 版本号非法 + """ + dir_name = plugin_version_dir_name(version) + manifest = read_plugin_versions_manifest(plugin_root) + previous_current = manifest.get("current") + previous_current = ( + previous_current if isinstance(previous_current, str) and previous_current else None + ) + versions = [ + entry + for entry in (manifest.get("versions") or []) + if isinstance(entry, dict) and entry.get("version") != version + ] + versions.append( + { + "version": version, + "directory": dir_name, + "installed_at": datetime.now(timezone.utc).isoformat(), + "source": source, + } + ) + write_plugin_versions_manifest(plugin_root, versions, version) + return dir_name, previous_current + + +def _find_leftover_layout_staging(plugin_root: Path) -> Path | None: + """在插件源码目录同级查找上次迁移中断遗留的改名中转目录。 + + :param plugin_root: 插件源码根目录 + :return: 遗留的中转目录;不存在时为 None + """ + parent = plugin_root.parent + if not parent.is_dir(): + return None + prefix = f"{plugin_root.name}{_PLUGIN_LAYOUT_STAGING_INFIX}" + candidates = sorted( + entry + for entry in parent.iterdir() + if entry.is_dir() and entry.name.startswith(prefix) + ) + return candidates[0] if candidates else None + + +def _is_reserved_layout_entry(entry: Path) -> bool: + """判断插件源码目录下的条目是否属于版本化布局自身,不参与存量迁移。 + + :param entry: 插件源码目录下的条目 + :return: 是版本目录或元信息文件时为 True + """ + if entry.name == PLUGIN_VERSIONS_MANIFEST_NAME: + return True + return entry.is_dir() and plugin_version_from_dir_name(entry.name) is not None + + +def migrate_legacy_plugin_layout(plugin_root: Path) -> Path | None: + """把平铺布局的存量插件源码原地迁移为按版本分目录的布局。 + + 先把平铺源码改名搬到同级中转目录,再一次改名落到版本目录,最后登记版本 + 元信息;元信息写入同时充当迁移完成哨兵,中断后重入会发现遗留的中转目录 + 并续做。跨设备无法原子改名时放弃迁移,插件继续按存量布局加载——加载路径 + 只在真正要装第二个版本时才调用本函数,平时的加载不会触发磁盘改动。 + + :param plugin_root: 插件源码根目录 + :return: 迁移后的版本目录;无需迁移时为 None;放弃迁移时为仍持有源码的目录 + """ + staging = _find_leftover_layout_staging(plugin_root) + # 只有插件目录下直接放着主模块才算存量平铺布局;已迁移目录里的杂项条目 + # (构建残留、临时文件)不能被当成一个待迁移的版本 + has_flat_source = (plugin_root / "__init__.py").is_file() + if staging is None and not has_flat_source: + return None + pending = ( + [ + entry + for entry in plugin_root.iterdir() + if not _is_reserved_layout_entry(entry) + ] + if has_flat_source + else [] + ) + + source_root = staging if staging is not None and staging.is_dir() else plugin_root + version = read_declared_plugin_version(source_root / "__init__.py") + if not version: + version = PLUGIN_FALLBACK_VERSION + logger.warning( + f"插件 {plugin_root.name} 未声明版本号,存量源码按兜底版本 " + f"{PLUGIN_FALLBACK_VERSION} 迁移" + ) + try: + dir_name = plugin_version_dir_name(version) + except ValueError as err: + logger.error(f"插件 {plugin_root.name} 版本号无法映射为版本目录,放弃迁移:{err}") + return plugin_root if (plugin_root / "__init__.py").is_file() else None + + target = plugin_root / dir_name + if staging is None: + staging = plugin_root.parent / ( + f"{plugin_root.name}{_PLUGIN_LAYOUT_STAGING_INFIX}{uuid.uuid4().hex}" + ) + try: + if pending: + staging.mkdir(parents=True, exist_ok=True) + for entry in pending: + os.rename(entry, staging / entry.name) + if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + os.rename(staging, target) + except OSError as error: + if getattr(error, "errno", None) == errno.EXDEV: + logger.warning( + f"插件源码目录跨设备无法原子改名,放弃本次迁移:{plugin_root} - {error}" + ) + else: + logger.error(f"插件源码目录迁移失败:{plugin_root} - {error}") + if staging.is_dir() and not any(staging.iterdir()): + staging.rmdir() + if (plugin_root / "__init__.py").is_file(): + return plugin_root + return staging if staging.is_dir() else None + + try: + register_plugin_version(plugin_root, version, source="migrated") + except OSError as error: + logger.warning(f"插件版本元信息写入失败,下次加载将重试:{plugin_root} - {error}") + return target + + +def read_declared_plugin_version(init_file: Path) -> str | None: + """静态解析插件主模块声明的版本号,不导入插件代码。 + + :param init_file: 插件主模块 __init__.py 路径 + :return: 版本号;解析不到时为 None + """ + try: + tree = ast.parse(init_file.read_text(encoding="utf-8", errors="replace")) + except (OSError, SyntaxError, ValueError): + return None + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + for statement in node.body: + targets: list[ast.expr] + if isinstance(statement, ast.Assign): + targets = statement.targets + elif isinstance(statement, ast.AnnAssign): + targets = [statement.target] + else: + continue + if not any( + isinstance(target, ast.Name) and target.id == "plugin_version" + for target in targets + ): + continue + value = statement.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return value.value.strip() or None + return None + + +def resolve_plugin_version_dir(plugin_root: Path, version: str | None = None) -> Path: + """定位插件本次要加载的源码目录。 + + 指定版本时返回该版本的版本目录;未指定时返回版本元信息登记的当前版本目录, + 元信息缺失或指向磁盘上不存在的目录时回落到版本号最高的已装版本。插件根目录 + 下没有任何版本目录时,视为存量平铺布局,回落到插件根目录本身,使今天没有 + 安装任何版本目录的插件加载路径与本函数引入前逐字一致。 + + :param plugin_root: 插件源码根目录 + :param version: 指定加载的版本号,为空时取元信息里的当前版本 + :return: 源码目录;没有版本目录时为插件根目录本身 + :raise ValueError: 指定的版本号没有对应的已装版本目录 + """ + on_disk = plugin_version_dirs(plugin_root) + if not on_disk: + return plugin_root + + if version: + target = on_disk.get(version) + if target is None: + raise ValueError(f"插件 {plugin_root.name} 未安装版本 {version}") + return target + + manifest = read_plugin_versions_manifest(plugin_root) + current = manifest.get("current") + if isinstance(current, str) and current: + if current in on_disk: + return on_disk[current] + logger.warning( + f"插件 {plugin_root.name} 元信息登记的当前版本 {current} 在磁盘上不存在," + f"回落到版本号最高的已装版本" + ) + + newest = next(iter(sorted(on_disk))) + for candidate in on_disk: + if compare_version(candidate, ">", newest): + newest = candidate + return on_disk[newest] + + +def resolve_instance_version_dir( + plugin_root: Path, + instance: PluginInstance | None, +) -> Path: + """按虚拟实例的版本绑定解析源插件应读取的源码目录。 + + 源插件本身或未传入实例时按插件当前版本解析;实例跟随当前版本时同样按 + 当前版本解析,不跟随时按实例自身绑定的版本解析。绑定版本的目录已不在 + 磁盘上时回落到当前版本,语义与加载器对同一绑定失效场景的处理一致, + 避免静态资源与已加载代码分处不同版本目录。 + + :param plugin_root: 源插件源码根目录 + :param instance: 虚拟实例描述;为空表示直接按源插件本身解析 + :return: 源码目录;没有版本目录的存量布局时为插件根目录本身 + """ + desired_version = ( + None if instance is None or instance.follow_current_version else instance.plugin_version + ) + try: + return resolve_plugin_version_dir(plugin_root, desired_version) + except ValueError: + return resolve_plugin_version_dir(plugin_root) + + +def _delete_plugin_version_dir(plugin_root: Path, version: str, directory: Path) -> bool: + """删除单个插件版本目录,删除前三重校验,任一不通过即拒绝且不删除。 + + 校验顺序:目录 ``resolve()`` 后确认位于插件目录之内;确认不等于插件目录 + 本身;确认目录名能反解回待删除的版本号本身,据此排除 dist、wheels、 + __pycache__ 等保留条目,也排除元信息与磁盘目录名不一致的条目。删除失败 + (占用、权限等)只记错误日志、不向上抛出,不影响其余版本的回收。 + + :param plugin_root: 插件源码根目录 + :param version: 待删除的版本号 + :param directory: 待删除的版本目录 + :return: 是否已删除 + """ + resolved_root = plugin_root.resolve() + resolved_dir = directory.resolve() + checks_passed = ( + resolved_dir.is_relative_to(resolved_root) + and resolved_dir != resolved_root + and plugin_version_from_dir_name(resolved_dir.name) == version + ) + if not checks_passed: + logger.error(f"插件版本目录校验未通过,跳过删除:{resolved_dir}") + return False + try: + shutil.rmtree(resolved_dir) + return True + except OSError as error: + logger.error(f"插件版本目录删除失败:{resolved_dir} - {error}") + return False + + +def recycle_plugin_version_directories( + plugin_root: Path, + referenced_versions: set[str], + retention: int = PLUGIN_VERSION_RETENTION_WINDOW, +) -> dict[str, Any]: + """回收插件源码目录下没有实例引用、也不在保留窗口内的旧版本目录。 + + 保留判据满足其一即保留,且判据取值均为调用方实测的运行态与配置,本函数 + 不按目录时间戳猜测:该版本是版本元信息登记的当前安装版本;该版本落在 + ``referenced_versions`` 里——调用方须确保该集合已经并入实例的已生效版本 + 与按跟随开关解析出的期望版本两者,否则会删掉正在用或即将切换到的版本; + 该版本按登记时间排在最近 ``retention`` 个以内。删除前逐一重新校验目录仍 + 是该插件下的合法版本目录,单个目录删除失败不影响其余目录的回收,最后把 + 已删除的版本从已装版本清单中一并摘除。 + + :param plugin_root: 插件源码根目录 + :param referenced_versions: 当前被实例占用的版本号集合(已生效版本 ∪ 按跟随 + 开关解析出的期望版本),由调用方基于实测的实例配置算出 + :param retention: 额外按登记时间保留的最近版本数,含当前版本,取值理由见 + ``PLUGIN_VERSION_RETENTION_WINDOW`` + :return: 含 removed(已删除版本号列表)与 kept(版本号到保留理由的映射)的字典 + """ + on_disk = plugin_version_dirs(plugin_root) + if not on_disk: + return {"removed": [], "kept": {}} + + manifest = read_plugin_versions_manifest(plugin_root) + current = manifest.get("current") + current_version = current if isinstance(current, str) and current else None + entries = { + entry["version"]: entry + for entry in (manifest.get("versions") or []) + if isinstance(entry, dict) and isinstance(entry.get("version"), str) + } + + def installed_at(version: str) -> str: + """返回版本的登记时间,缺失时排到最旧,不占用保留窗口的名额。""" + return (entries.get(version) or {}).get("installed_at") or "" + + recent_window = set(sorted(on_disk, key=installed_at, reverse=True)[: max(retention, 0)]) + + kept: dict[str, str] = {} + for version in on_disk: + if version == current_version: + kept[version] = "当前安装版本" + elif version in referenced_versions: + kept[version] = "被实例引用(已生效版本或按跟随开关解析出的期望版本)" + elif version in recent_window: + kept[version] = f"保留窗口内(按登记时间的最近 {retention} 个版本)" + + removed: list[str] = [] + for version in sorted(on_disk): + if version in kept: + continue + if _delete_plugin_version_dir(plugin_root, version, on_disk[version]): + removed.append(version) + else: + kept[version] = "本次删除失败,下次回收重试" + + if removed: + remaining_versions = [ + entry + for entry in (manifest.get("versions") or []) + if isinstance(entry, dict) and entry.get("version") not in removed + ] + write_plugin_versions_manifest(plugin_root, remaining_versions, current_version) + + return {"removed": removed, "kept": kept} + + +def remove_plugin_installed_version( + plugin_root: Path, + version: str, + previous_current: str | None, +) -> None: + """回滚一次失败的版本化安装:删除该版本目录并从版本元信息摘除,精确复原当前版本。 + + 只清理调用方指定的这一个版本,不牵连插件目录下的其它已装版本——多版本 + 并存下安装失败清理的范围必须收敛到本次安装尝试本身,否则会连带删掉正被 + 其它实例绑定的版本。删除后若插件目录既没有其它版本目录、也没有平铺布局 + 的主模块,视为清理干净的空壳,连插件目录本身与版本元信息一并删除,不留 + 安装失败的残留登记;仍有其它版本时,若被摘除的版本恰好是元信息登记的 + 当前版本(写入版本目录成功后会乐观置为当前版本,早于依赖安装校验完成), + 精确复原为 ``previous_current``——登记本次失败版本之前元信息里的当前 + 版本,由版本登记函数在写入前读出并逐层穿透到这里,据此精确复原而不是 + 按剩余版本里语义号最高者去猜。``previous_current`` 为 None 表示登记前 + 插件没有任何已装版本,复原后当前版本同样置空;``previous_current`` 指向 + 的版本在回滚时已不在剩余版本清单中(理论上不该发生,版本登记函数只在 + 原子替换当前版本前追加新条目、不会摘除其它条目),按同样口径置空,不去 + 猜一个可能已经与磁盘状态脱节的版本号。 + + :param plugin_root: 插件源码根目录 + :param version: 安装失败需要回滚的版本号 + :param previous_current: 登记本次失败版本之前元信息里的当前版本号,由 + ``register_plugin_version`` 返回并逐层穿透而来;插件在本次登记前 + 没有任何已装版本时为 None + """ + directory = plugin_version_dirs(plugin_root).get(version) + if directory is not None: + _delete_plugin_version_dir(plugin_root, version, directory) + + if not plugin_version_dirs(plugin_root) and not (plugin_root / "__init__.py").is_file(): + shutil.rmtree(plugin_root, ignore_errors=True) + return + + manifest = read_plugin_versions_manifest(plugin_root) + remaining_versions = [ + entry + for entry in (manifest.get("versions") or []) + if isinstance(entry, dict) and entry.get("version") != version + ] + current = manifest.get("current") + if current == version: + remaining_version_numbers = {entry.get("version") for entry in remaining_versions} + current = previous_current if previous_current in remaining_version_numbers else None + write_plugin_versions_manifest(plugin_root, remaining_versions, current) diff --git a/app/runtime/log.py b/app/runtime/log.py index 35515b33f4..654f7f4145 100644 --- a/app/runtime/log.py +++ b/app/runtime/log.py @@ -1,6 +1,10 @@ +"""日志基础设施:等级过滤、控制台与文件路由、插件实例日志等级覆盖。""" + from __future__ import annotations import asyncio +import functools +import inspect import logging import os import queue @@ -8,11 +12,14 @@ import threading import time from collections import deque +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path from types import FrameType -from typing import Any, Callable, Dict, Optional, Protocol, Self +from typing import Any, Callable, Dict, Iterator, Optional, Protocol, Self, Tuple import click from pydantic import BaseModel, ConfigDict @@ -103,6 +110,179 @@ def _get_log_correlation_id() -> str: return _correlation_id_provider() or "-" +# 插件实例日志等级允许的取值,与标准库 logging 的等级名保持一致。 +LOG_LEVELS: Tuple[str, ...] = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + + +def _current_global_log_level() -> int: + """返回当前全局日志策略对应的标准库日志级别。""" + if log_settings.DEBUG: + return logging.DEBUG + return getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) + + +@dataclass(frozen=True) +class _PluginLevelOverride: + """一个插件实例的日志等级覆盖。""" + + level: int + level_name: str + expires_at: Optional[float] + + +# 宿主在构造、init_plugin、事件分发、定时任务、API 端点等自己控制的调用点用它绑定 +# 当前插件实例;插件自建的原生线程不会继承这里绑定的取值(contextvars 只在同一 +# 协程/任务链内传播),这类线程内的日志按全局等级过滤,不属于本机制的覆盖范围。 +_current_plugin_instance: "ContextVar[Optional[str]]" = ContextVar( + "current_plugin_instance", default=None +) + +# 插件实例日志等级覆盖缓存:等级来自数据库,避免每条日志都查库;写入源是日志控制 +# API(配置变更时直接调用 set/clear,立即生效)和启动组合根(进程重启后从数据库 +# 预热);覆盖的过期回落在读取时惰性判定并清理,不额外起后台线程扫描。 +# +# `LoggerManager.logger` 只在 `current_plugin_instance_id()` 命中时才查这份缓存 +# (未绑定实例的日志直接按全局等级过滤),因此不需要额外维护一个全局快速闸—— +# ContextVar 读取本身已经足够便宜,也避免了「某个实例的覆盖不小心放宽了所有未绑定 +# 日志的过滤阈值」这类跨实例串扰。 +_plugin_level_overrides: Dict[str, _PluginLevelOverride] = {} +_plugin_level_lock = threading.RLock() + + +def set_plugin_instance_log_level( + instance_id: str, + level: str, + expires_at: Optional[datetime] = None, +) -> None: + """ + 设置插件实例的日志等级覆盖,写入进程内缓存并立即生效。 + + 只维护运行期生效状态;把覆盖持久化到数据库是调用方(日志控制 API、启动组合根 + 的缓存预热)的职责,本函数不做任何数据库读写。 + :param instance_id: 实例 ID,源插件本体自身的版本绑定与虚拟实例共用同一命名空间 + :param level: 目标等级,取值须在 LOG_LEVELS 内 + :param expires_at: 覆盖失效时间,None 表示不过期 + :raises ValueError: level 不是受支持的等级名 + """ + normalized = (level or "").strip().upper() + if normalized not in LOG_LEVELS: + raise ValueError(f"不支持的日志等级:{level}") + entry = _PluginLevelOverride( + level=getattr(logging, normalized), + level_name=normalized, + expires_at=expires_at.timestamp() if expires_at else None, + ) + with _plugin_level_lock: + _plugin_level_overrides[instance_id] = entry + + +def clear_plugin_instance_log_level(instance_id: str) -> None: + """ + 清除插件实例的日志等级覆盖,运行期立即回落全局等级。 + :param instance_id: 实例 ID + """ + with _plugin_level_lock: + _plugin_level_overrides.pop(instance_id, None) + + +def get_plugin_instance_log_level_override( + instance_id: str, +) -> Optional[Tuple[str, Optional[datetime]]]: + """ + 返回插件实例当前缓存的原始等级覆盖设置,未设置或已过期时为 None。 + :param instance_id: 实例 ID + :return: `(等级名, 失效时间)`;失效时间为 None 表示不过期 + """ + with _plugin_level_lock: + entry = _plugin_level_overrides.get(instance_id) + if entry is None: + return None + if entry.expires_at is not None and entry.expires_at <= time.time(): + del _plugin_level_overrides[instance_id] + return None + expires_dt = ( + datetime.fromtimestamp(entry.expires_at) if entry.expires_at else None + ) + return entry.level_name, expires_dt + + +def get_effective_plugin_instance_log_level(instance_id: str) -> str: + """ + 返回插件实例当前生效的日志等级名,覆盖过期时回落全局等级。 + :param instance_id: 实例 ID + :return: 等级名,如 "DEBUG" + """ + override = get_plugin_instance_log_level_override(instance_id) + if override is not None: + return override[0] + return "DEBUG" if log_settings.DEBUG else log_settings.LOG_LEVEL.upper() + + +def _effective_instance_level_int(instance_id: str) -> int: + """返回插件实例过滤日志时实际使用的等级整数,供 `LoggerManager.logger` 精确过滤。""" + with _plugin_level_lock: + entry = _plugin_level_overrides.get(instance_id) + if entry is None: + return _current_global_log_level() + if entry.expires_at is not None and entry.expires_at <= time.time(): + del _plugin_level_overrides[instance_id] + return _current_global_log_level() + return entry.level + + +def current_plugin_instance_id() -> Optional[str]: + """返回当前受控调用点绑定的插件实例 ID,未绑定时为 None。""" + return _current_plugin_instance.get() + + +@contextmanager +def bind_plugin_instance(instance_id: str) -> Iterator[None]: + """ + 在宿主自己控制的调用点(构造、init_plugin、事件分发、定时任务、API 端点……)内 + 绑定当前插件实例,供日志等级过滤使用。 + + 绑定只在当前协程/任务链内生效;插件自建的原生线程不继承这个绑定。 + :param instance_id: 实例 ID + """ + token = _current_plugin_instance.set(instance_id) + try: + yield + finally: + _current_plugin_instance.reset(token) + + +def wrap_for_plugin_instance( + func: Callable[..., Any], instance_id: str +) -> Callable[..., Any]: + """ + 包装一个插件回调,使其执行期间的日志按指定实例过滤等级。 + + 用于回调在注册时被捕获、稍后才由宿主(如调度器、HTTP 路由)调用的场景; + 绑定发生在包装函数自身调用内部,因此不依赖调用方所在协程/线程如何传播 + 上下文。同步/异步函数各自返回同型包装,`inspect.iscoroutinefunction` + 等自省结果不变。 + :param func: 插件提供的原始回调,通常是插件实例的绑定方法 + :param instance_id: 实例 ID + :return: 包装后的可调用对象 + """ + if inspect.iscoroutinefunction(func): + @functools.wraps(func) + async def _async_wrapped(*args: Any, **kwargs: Any) -> Any: + """在绑定实例上下文内等待原始协程回调。""" + with bind_plugin_instance(instance_id): + return await func(*args, **kwargs) + + return _async_wrapped + + @functools.wraps(func) + def _sync_wrapped(*args: Any, **kwargs: Any) -> Any: + """在绑定实例上下文内调用原始同步回调。""" + with bind_plugin_instance(instance_id): + return func(*args, **kwargs) + + return _sync_wrapped + + class NonBlockingFileHandler: """使用后台队列和滚动文件处理器写入业务日志。""" @@ -512,9 +692,7 @@ def update_loggers(self) -> None: @staticmethod def _get_log_level() -> int: """返回当前日志策略对应的标准库日志级别。""" - if log_settings.DEBUG: - return logging.DEBUG - return getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) + return _current_global_log_level() @classmethod def _write_file_log(cls, level: str, message: str, logfile: Path) -> None: @@ -528,9 +706,21 @@ def _write_file_log(cls, level: str, message: str, logfile: Path) -> None: writer.write_log(level, message, log_path / logfile) def logger(self, method: str, msg: str, *args: Any, **kwargs: Any) -> None: - """按调用来源路由并输出一条日志。""" + """按调用来源路由并输出一条日志。 + + 等级过滤只看 `current_plugin_instance_id()`:命中受控调用点绑定的插件 + 实例时按该实例的覆盖等级过滤(未设置覆盖时等同全局等级),未绑定任何 + 实例时直接按全局等级过滤,不依赖栈回溯识别出的调用来源,因此不影响 + 既有的文件路由逻辑,也不会让某个实例的覆盖影响到其它日志的过滤阈值。 + """ method_level = getattr(logging, method.upper(), logging.INFO) - if method_level < self._get_log_level(): + instance_id = current_plugin_instance_id() + effective_level = ( + _effective_instance_level_int(instance_id) + if instance_id + else _current_global_log_level() + ) + if method_level < effective_level: return caller_name, plugin_name = self._get_caller() diff --git a/app/scheduler/reconcile.py b/app/scheduler/reconcile.py index 5676ff2e51..6fd4a1da5a 100644 --- a/app/scheduler/reconcile.py +++ b/app/scheduler/reconcile.py @@ -21,7 +21,7 @@ ) from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.workflow import WorkflowSnapshot -from app.runtime.log import logger +from app.runtime.log import logger, wrap_for_plugin_instance from app.runtime.scheduling import TimerUtils from app.scheduler.contract import _SchedulerOwnerBase from app.schemas.message import Message @@ -427,7 +427,7 @@ def update_plugin_job(self, pid: str) -> None: job = JobSpec( job_id, service["name"], - service["func"], + wrap_for_plugin_instance(service["func"], pid), f"plugin:{pid}", kwargs=service.get("func_kwargs") or {}, ).to_runtime_state() diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 3a1245dea5..4435cdbdff 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -338,7 +338,13 @@ 'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'), 'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'), 'PluginInstallOutcome': ('app.schemas.plugin', 'PluginInstallOutcome'), + 'PluginInstalledVersionInfo': ('app.schemas.plugin', 'PluginInstalledVersionInfo'), 'PluginInstance': ('app.schemas.plugin', 'PluginInstance'), + 'PluginInstanceLogLevel': ('app.schemas.plugin', 'PluginInstanceLogLevel'), + 'PluginInstanceLogLevelOverview': ('app.schemas.plugin', 'PluginInstanceLogLevelOverview'), + 'PluginInstanceLogLevelUpdateRequest': ('app.schemas.plugin', 'PluginInstanceLogLevelUpdateRequest'), + 'PluginInstanceVersionBinding': ('app.schemas.plugin', 'PluginInstanceVersionBinding'), + 'PluginInstanceVersionUpdateRequest': ('app.schemas.plugin', 'PluginInstanceVersionUpdateRequest'), 'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'), 'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'), 'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'), @@ -361,6 +367,8 @@ 'PluginSourceOptions': ('app.schemas.plugin', 'PluginSourceOptions'), 'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'), 'PluginUpdateCandidate': ('app.schemas.plugin', 'PluginUpdateCandidate'), + 'PluginVersionOverview': ('app.schemas.plugin', 'PluginVersionOverview'), + 'PluginVersionRecycleOutcome': ('app.schemas.plugin', 'PluginVersionRecycleOutcome'), 'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'), 'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'), 'ProgressKeyData': ('app.schemas.common', 'ProgressKeyData'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 62a5c9285b..82bd67ddd1 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -1,3 +1,4 @@ +from datetime import datetime as _datetime from enum import Enum as _Enum from typing import Annotated as _Annotated from typing import Dict, List, Literal, Optional, Union @@ -62,7 +63,108 @@ class PluginInstance(BaseModel): plugin_name: Optional[str] = Field(default=None, description="实例展示名称") plugin_desc: Optional[str] = Field(default=None, description="实例展示描述") plugin_icon: Optional[str] = Field(default=None, description="实例展示图标") - mode: Literal["virtual"] = Field(default="virtual", description="实例实现模式") + mode: Literal["virtual", "host"] = Field( + default="virtual", + description="实例实现模式:virtual 为共享源码的分身,host 为源插件本体自身的版本绑定", + ) + plugin_version: Optional[str] = Field( + default=None, + description="该实例已生效的插件版本,None 表示尚未成功启动过任何版本", + ) + follow_current_version: bool = Field( + default=True, + description="是否跟随插件当前版本;为 False 时固定使用 plugin_version 绑定的版本", + ) + log_level: Optional[str] = Field( + default=None, + description="该实例的日志等级覆盖,None 表示跟随全局日志等级", + ) + log_expires_at: Optional[_datetime] = Field( + default=None, + description="日志等级覆盖的失效时间,None 表示不过期", + ) + is_default_target: bool = Field( + default=False, + description="该实例是否为所属源插件的默认调用目标", + ) + + +class PluginInstalledVersionInfo(BaseModel): # type: ignore[misc] + """插件某个已装版本的落盘信息。""" + + version: str = Field(description="版本号") + directory: str = Field(description="版本源码目录名") + installed_at: Optional[str] = Field(default=None, description="安装时间,ISO 格式") + source: Optional[str] = Field(default=None, description="版本来源,如 market、local、migrated") + is_current: bool = Field(description="是否为版本元信息登记的当前版本") + + +class PluginInstanceVersionBinding(BaseModel): # type: ignore[misc] + """单个实例的版本绑定与运行状态。""" + + instance_id: str = Field(description="实例 ID") + plugin_version: Optional[str] = Field(default=None, description="该实例已生效的插件版本") + follow_current_version: bool = Field(description="是否跟随插件当前版本") + running: bool = Field(description="该实例当前是否运行中") + is_host: bool = Field(default=False, description="是否为源插件本体自身,而非共享源码的分身") + is_default_target: bool = Field( + default=False, description="该实例是否为本插件的默认调用目标" + ) + + +class PluginVersionOverview(BaseModel): # type: ignore[misc] + """插件已装版本总览与各实例的版本绑定。""" + + plugin_id: str = Field(description="插件 ID") + current_version: Optional[str] = Field(default=None, description="版本元信息登记的当前版本") + installed_versions: List[PluginInstalledVersionInfo] = Field( + default_factory=list, description="已装版本列表,按版本号升序排列" + ) + instances: List[PluginInstanceVersionBinding] = Field( + default_factory=list, description="引用该插件源码的各实例版本绑定" + ) + + +class PluginInstanceVersionUpdateRequest(BaseModel): # type: ignore[misc] + """设置实例版本绑定的请求参数。""" + + follow_current_version: bool = Field(description="是否跟随插件当前版本") + plugin_version: Optional[str] = Field( + default=None, + description="不跟随当前版本时必填,且必须是已安装版本", + ) + + +class PluginVersionRecycleOutcome(BaseModel): # type: ignore[misc] + """插件已装版本目录回收结果。""" + + removed: List[str] = Field(default_factory=list, description="本次已删除的版本号列表") + kept: Dict[str, str] = Field(default_factory=dict, description="版本号到保留理由的映射") + + +class PluginInstanceLogLevel(BaseModel): # type: ignore[misc] + """单个实例的日志等级设置与生效结果。""" + + instance_id: str = Field(description="实例 ID") + configured_level: Optional[str] = Field(default=None, description="该实例设置的日志等级覆盖,None 表示未设置或已过期") + expires_at: Optional[_datetime] = Field(default=None, description="日志等级覆盖的失效时间,None 表示不过期") + effective_level: str = Field(description="按过期回落判定后实际生效的日志等级") + + +class PluginInstanceLogLevelOverview(BaseModel): # type: ignore[misc] + """插件全部实例(含本体)的日志等级设置总览。""" + + plugin_id: str = Field(description="插件 ID") + instances: List[PluginInstanceLogLevel] = Field( + default_factory=list, description="该插件全部实例的日志等级设置,首项固定是本体自身" + ) + + +class PluginInstanceLogLevelUpdateRequest(BaseModel): # type: ignore[misc] + """设置实例日志等级覆盖的请求参数。""" + + level: str = Field(description="目标日志等级,如 DEBUG、INFO、WARNING、ERROR、CRITICAL") + expires_at: Optional[_datetime] = Field(default=None, description="覆盖失效时间,None 表示不过期") class Plugin(BaseModel): @@ -132,6 +234,12 @@ class Plugin(BaseModel): is_instance: Optional[bool] = False # 实例实现模式;存量物理分身为空 instance_mode: Optional[str] = None + # 该实例钉住的插件版本;跟随插件当前版本时为空 + pinned_version: Optional[str] = None + # 该实例是否为所属插件的默认调用目标 + is_default_target: bool = False + # 该实例当前生效的日志等级覆盖;未设置覆盖或覆盖已过期回落全局等级时为空 + log_level_effective: Optional[str] = None @property def package_version(self) -> Optional[str]: diff --git a/app/startup/composition/plugin.py b/app/startup/composition/plugin.py index a34f4d11a6..919e461ff9 100644 --- a/app/startup/composition/plugin.py +++ b/app/startup/composition/plugin.py @@ -13,10 +13,131 @@ ) from app.adapters.system.plugin.dependency import PluginDependencyInstaller from app.adapters.system.plugin.health import PluginRuntimeHealth -from app.adapters.system.plugin.package import PluginPackageManager +from app.adapters.system.plugin.package import PluginInstallVersionTarget, PluginPackageManager +from app.runtime.compat.readiness import plugin_multi_version_blockers +from app.runtime.extensions.plugin.version import ( + PLUGIN_FALLBACK_VERSION, + ensure_plugin_version_dir_available, + migrate_legacy_plugin_layout, + plugin_version_dirs, + read_declared_plugin_version, + register_plugin_version, + remove_plugin_installed_version, +) from app.runtime.settings import get_runtime_setting +def _reject_incompatible_plugin_version_switch( + plugin_id: str, + plugin_dir: Path, + source_dir: Path, +) -> Optional[str]: + """判定插件从已装版本切换到另一版本能否在安装期被接受。 + + 只在声明版本号确实发生变化时才检查——同版本重新同步是开发闭环的日常操作, + 不是在装另一个版本,不需要为此扫描全部源码。命中自引用绝对导入或宿主共享 + 声明基类建模时拒绝:这两类写法在真正的多版本并存下必然失败,把故障从运行 + 期提前到安装时。这个组合只能落在组合根——版本目录布局属于运行时扩展包, + 写法体检属于兼容层静态扫描,两者都不允许被适配器或运行时扩展包本身引用。 + + :param plugin_id: 插件ID + :param plugin_dir: 插件当前运行目录;未声明源码(尚未安装)时不检查 + :param source_dir: 待安装的插件源码目录 + :return: 拒绝说明;无需拒绝时为 None + """ + installed_init = plugin_dir / "__init__.py" + if not installed_init.is_file(): + return None + installed_version = read_declared_plugin_version(installed_init) or PLUGIN_FALLBACK_VERSION + incoming_version = ( + read_declared_plugin_version(source_dir / "__init__.py") or PLUGIN_FALLBACK_VERSION + ) + if installed_version == incoming_version: + return None + blockers = plugin_multi_version_blockers(plugin_id.lower(), [plugin_dir, source_dir]) + if not blockers: + return None + return ( + f"插件 {plugin_id} 的写法不支持多版本并存,拒绝从 {installed_version} 版本切换到 " + f"{incoming_version} 版本:" + ";".join(blockers) + ) + + +def _resolve_plugin_install_target( + plugin_id: str, + plugin_dir: Path, + staged_source_dir: Path, +) -> Optional[PluginInstallVersionTarget]: + """决定已就位的暂存源码应当落盘到插件根目录下的哪个版本子目录。 + + 调用方需确保并存检查已经通过——本函数只做机械的目标目录决策,不重复扫描 + 写法。声明版本号缺失时沿用平铺布局,不为无版本号的插件强行造版本目录; + 已装内容是平铺布局且声明版本号与待装版本相同时同样留在平铺布局,视为一次 + 原地重装,不为同版本重装凭空造出版本目录。其余情况下需要一个版本目录来 + 承载待装内容:仍是平铺布局时先把存量源码原地迁移腾出插件根目录,迁移失败 + 时拒绝安装以保住存量源码的可加载性;已经是版本化布局时直接申请目录名。 + 这个组合只能落在组合根——版本目录布局和存量迁移都属于运行时扩展包,不允许 + 被适配器层引用。 + + :param plugin_id: 插件ID + :param plugin_dir: 插件根目录;可能尚不存在 + :param staged_source_dir: 已就位的待装源码目录 + :return: 版本目录名与版本号;沿用平铺布局时为 None + :raise ValueError: 版本号不是合法目录名,或与已装版本大小写撞名 + :raise RuntimeError: 存量平铺布局迁移到版本目录失败 + """ + incoming_version = read_declared_plugin_version(staged_source_dir / "__init__.py") + if not incoming_version: + return None + + flat_init = plugin_dir / "__init__.py" + if not plugin_version_dirs(plugin_dir) and flat_init.is_file(): + installed_version = read_declared_plugin_version(flat_init) or PLUGIN_FALLBACK_VERSION + if installed_version == incoming_version: + return None + migrated = migrate_legacy_plugin_layout(plugin_dir) + if migrated is None or migrated.parent != plugin_dir: + raise RuntimeError( + f"插件 {plugin_id} 存量源码迁移到版本目录失败,安装已取消" + ) + + dir_name = ensure_plugin_version_dir_available(plugin_dir, incoming_version) + return PluginInstallVersionTarget(subdirectory=dir_name, version=incoming_version) + + +def _register_plugin_install_version( + plugin_dir: Path, version: str, source: str +) -> Optional[str]: + """把已落盘的版本目录登记进版本元信息并置为当前版本,返回登记前的当前版本号。 + + 返回值供安装失败清理据此精确复原当前版本,不必在回滚时靠猜。 + + :param plugin_dir: 插件根目录 + :param version: 已落盘的版本号 + :param source: 版本来源标签,如 market、local + :return: 登记前元信息里的当前版本号;插件在本次登记前没有任何已装版本时为 None + """ + _, previous_current = register_plugin_version(plugin_dir, version, source) + return previous_current + + +def _rollback_plugin_install_version( + plugin_dir: Path, version: str, previous_current: Optional[str] +) -> None: + """安装失败时回滚单个版本目录及其版本元信息登记,不牵连插件的其它已装版本。 + + 版本目录布局和版本元信息读写都属于运行时扩展包,不允许被适配器层引用, + 因此只能落在组合根。 + + :param plugin_dir: 插件根目录 + :param version: 安装失败需要回滚的版本号 + :param previous_current: 登记本次失败版本之前元信息里的当前版本号,由 + ``_register_plugin_install_version`` 返回并逐层穿透而来;插件在 + 本次登记前没有任何已装版本时为 None + """ + remove_plugin_installed_version(plugin_dir, version, previous_current) + + @dataclass(frozen=True, slots=True) class PluginMarketComposition: """保存插件市场相关 Transport、Client、Package 和 Dependency owner。""" @@ -48,6 +169,10 @@ def compose_plugin_market( package=PluginPackageManager( source=PluginPackageSourceClient(transport), plugin_root=plugin_root, + version_switch_guard=_reject_incompatible_plugin_version_switch, + install_target_resolver=_resolve_plugin_install_target, + install_version_registrar=_register_plugin_install_version, + install_version_rollback=_rollback_plugin_install_version, ), dependency=PluginDependencyInstaller( health, diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 2186a0afee..9c53e904ab 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -77,7 +77,9 @@ ) from app.application.scheduling import update_plugin_job from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.db.models.plugininstance import PluginInstance as PluginInstanceRecord from app.db.oper.plugindata import PluginDataOper +from app.db.oper.plugininstance import PluginInstanceOper from app.db.plugin.registry import ( destroy_database, ensure_database, @@ -91,6 +93,7 @@ configure_legacy_import_diagnostics, scan_plugin_legacy_imports, ) +from app.runtime.compat.readiness import plugin_multi_version_blockers from app.runtime.compat.resources import scan_plugin_resource_imports from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.plugin.database import ( @@ -115,8 +118,11 @@ build_plugin_runtime, ) from app.runtime.extensions.plugin.storage import ( + PluginInstanceDirectory, PluginStorage, + configure_plugin_instance_directory, configure_plugin_storage, + get_plugin_instance_directory, get_plugin_storage, ) from app.runtime.extensions.plugin.system import ( @@ -124,12 +130,12 @@ configure_plugin_system, get_plugin_system, ) -from app.runtime.log import logger +from app.runtime.log import logger, set_plugin_instance_log_level from app.runtime.loop import main_loop_registry from app.runtime.resources import acquire_managed_resource from app.runtime.settings import get_runtime_setting from app.schemas.exception import PluginMutationRejectedError -from app.schemas.plugin import PluginRuntimeStatus +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey from app.startup.composition.plugin import ( compose_plugin_market, @@ -164,6 +170,87 @@ def _build_plugin_database() -> PluginDatabase: ) +def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance: + """把插件实例描述符表的 ORM 行投影为运行时端口使用的 Pydantic 描述。""" + return PluginInstance( + instance_id=record.instance_id, + source_plugin_id=record.source_plugin_id, + plugin_name=record.plugin_name, + plugin_desc=record.plugin_desc, + plugin_icon=record.plugin_icon, + mode=record.mode, + plugin_version=record.plugin_version, + follow_current_version=record.follow_current_version, + log_level=record.log_level, + log_expires_at=record.log_expires_at, + is_default_target=record.is_default_target, + ) + + +def _save_plugin_instance_record(instance: PluginInstance) -> None: + """把运行时实例描述写入插件实例描述符表,以实例 ID 为稳定键做新增或更新。""" + PluginInstanceOper().save( + instance_id=instance.instance_id, + source_plugin_id=instance.source_plugin_id, + plugin_name=instance.plugin_name, + plugin_desc=instance.plugin_desc, + plugin_icon=instance.plugin_icon, + mode=instance.mode, + plugin_version=instance.plugin_version, + follow_current_version=instance.follow_current_version, + log_level=instance.log_level, + log_expires_at=( + instance.log_expires_at.isoformat() if instance.log_expires_at else None + ), + is_default_target=instance.is_default_target, + ) + + +def _prime_plugin_instance_log_levels() -> None: + """进程启动时把数据库中已设置的实例日志等级覆盖预热进运行期缓存。 + + 过期覆盖也照常预热:过期判定统一在读取时惰性执行(见 `app.runtime.log`), + 这里不重复实现一份过期过滤逻辑。单条记录预热失败不得阻断其余记录。 + """ + for record in PluginInstanceOper().list_all(): + if not record.log_level: + continue + try: + expires_at = ( + datetime.fromisoformat(record.log_expires_at) + if record.log_expires_at + else None + ) + set_plugin_instance_log_level(record.instance_id, record.log_level, expires_at) + except ValueError as error: + logger.warning( + f"预热插件实例 {record.instance_id} 的日志等级覆盖失败:{error}" + ) + + +def _build_plugin_instance_directory() -> PluginInstanceDirectory: + """把插件实例描述符表端口装配到 db 层的独立表实现。""" + oper = PluginInstanceOper() + + def _get(instance_id: str) -> PluginInstance | None: + """按实例 ID 查询并投影为运行时描述。""" + record = oper.get(instance_id) + return _plugin_instance_from_record(record) if record is not None else None + + return PluginInstanceDirectory( + get=_get, + list_all=lambda: [ + _plugin_instance_from_record(record) for record in oper.list_all() + ], + list_by_source=lambda source_plugin_id: [ + _plugin_instance_from_record(record) + for record in oper.list_by_source(source_plugin_id) + ], + save=_save_plugin_instance_record, + delete=oper.delete, + ) + + def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None: """在执行旧插件顶层代码前准备其静态导入所需的宿主资源。""" for capability_id in scan_plugin_resource_imports(plugin_id, plugin_dir): @@ -180,6 +267,7 @@ def build_plugin_runtime_graph(host: PluginRuntimeHost) -> PluginRuntime: PluginRuntimeEnvironment( plugins_root=Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins", storage=lambda: get_plugin_storage(), + instance_directory=lambda: get_plugin_instance_directory(), system=lambda: get_plugin_system(), database=lambda: get_plugin_database(), catalog_factory=lambda mapper: _build_plugin_catalog(mapper), @@ -189,6 +277,13 @@ def build_plugin_runtime_graph(host: PluginRuntimeHost) -> PluginRuntime: remote_entry=host.get_plugin_remote_entry, development=lambda: bool(get_runtime_setting('DEV')), logger=logger, + multi_version_blockers=plugin_multi_version_blockers, + set_default_target=lambda source_plugin_id, instance_id: ( + PluginInstanceOper().set_default_target(source_plugin_id, instance_id) + ), + clear_default_target=lambda source_plugin_id: ( + PluginInstanceOper().clear_default_target(source_plugin_id) + ), ), tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS, ) @@ -424,6 +519,8 @@ async def async_install_from_compat_helper( delete_data=_delete_plugin_data, )) configure_plugin_database(_build_plugin_database()) + configure_plugin_instance_directory(_build_plugin_instance_directory()) + _prime_plugin_instance_log_levels() def _register_plugin_runtime(plugin_id: str) -> None: diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 8f5e6f0ff1..8ee99075e2 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -118,6 +118,10 @@ async def init_extra(): finally: plugin_manager.set_plugin_settling(False) plugin_manager.start_monitor() + try: + await offload_blocking_callback(plugin_manager.recycle_all_plugin_versions)() + except Exception as error: # noqa: BLE001 - 版本回收失败不能阻断启动收尾流程 + logger.error(f"插件版本回收时发生错误:{error}", exc_info=True) _log_runtime_gil_status() # 设置系统已修改标志 SystemHelper().set_system_modified() @@ -190,10 +194,14 @@ def _consume_shutdown_result(done: asyncio.Future) -> None: return False -def offload_shutdown_callback( +def offload_blocking_callback( callback: Callable[[], object], ) -> Callable[[], Awaitable[object]]: - """把明确会阻塞的同步关闭 owner 包装为异步生命周期回调。""" + """把明确会阻塞的同步 owner 包装为可等待的异步回调,供关闭步骤与启动收尾复用。 + + :param callback: 明确会阻塞事件循环的同步调用 + :return: 在线程池中执行该调用并等待其完成的异步回调 + """ async def invoke() -> object: return await run_in_threadpool_to_completion(callback) @@ -531,7 +539,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: dependencies=("插件备份恢复",), mode=LifecycleMode.NORMAL_ONLY, start=init_plugins, - stop=offload_shutdown_callback(finalize_plugins), + stop=offload_blocking_callback(finalize_plugins), start_order=90, stop_order=60, start_timeout_seconds=300, @@ -542,7 +550,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: name="插件变更监控", dependencies=("插件",), mode=LifecycleMode.NORMAL_ONLY, - stop=offload_shutdown_callback(stop_plugin_monitor), + stop=offload_blocking_callback(stop_plugin_monitor), stop_order=8, stop_timeout_seconds=10, stop_failure=LifecycleFailurePolicy.FAIL_FAST, @@ -646,7 +654,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: dependencies=("命令服务",), mode=LifecycleMode.NORMAL_ONLY, start=init_workflow, - stop=offload_shutdown_callback(stop_workflow), + stop=offload_blocking_callback(stop_workflow), start_order=140, stop_order=20, start_timeout_seconds=120, @@ -657,7 +665,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: name="插件备份", dependencies=("插件",), mode=LifecycleMode.NORMAL_ONLY, - stop=offload_shutdown_callback( + stop=offload_blocking_callback( lambda: SystemChain().backup_plugins() ), stop_order=10, diff --git a/app/workflow/actions/invoke_plugin.py b/app/workflow/actions/invoke_plugin.py index 3d1cefdc56..cc99defaa6 100644 --- a/app/workflow/actions/invoke_plugin.py +++ b/app/workflow/actions/invoke_plugin.py @@ -42,7 +42,11 @@ def execute(self, workflow_id: int, params: dict, context: ActionContext) -> Act if not params.plugin_id or not params.action_id: return context try: - plugin_actions = get_plugin_manager().get_plugin_actions(params.plugin_id) + plugin_manager = get_plugin_manager() + # 未指定实例的插件 ID 存在分身时须裁决默认调用目标,避免历史工作流在 + # 源插件本体停用、仅分身启用后静默失效 + resolved_plugin_id = plugin_manager.resolve_plugin_call_target(params.plugin_id) + plugin_actions = plugin_manager.get_plugin_actions(resolved_plugin_id) if not plugin_actions: logger.error(f"插件不存在: {params.plugin_id}") return context diff --git a/database/versions/281965691a20_3_0_29.py b/database/versions/281965691a20_3_0_29.py new file mode 100644 index 0000000000..c480613031 --- /dev/null +++ b/database/versions/281965691a20_3_0_29.py @@ -0,0 +1,155 @@ +"""3.0.29 插件实例描述符迁入独立表。 + +Revision ID: 281965691a20 +Revises: d8f2b6a4c1e7 +Create Date: 2026-09-02 +""" + +from datetime import datetime, timezone + +import sqlalchemy as sa +from alembic import op + +revision = "281965691a20" +down_revision = "d8f2b6a4c1e7" +branch_labels = None +depends_on = None + +_TABLE = "plugininstance" +_LEGACY_KEY = "PluginInstances" + + +def _id_column(dialect_name: str) -> sa.Column: + """保持 PostgreSQL Identity 与 SQLite 整数主键的当前模型语义一致。""" + if dialect_name == "postgresql": + return sa.Column( + "id", + sa.Integer(), + sa.Identity(start=1, cycle=True), + nullable=False, + ) + return sa.Column("id", sa.Integer(), nullable=False) + + +def _legacy_entries(connection: sa.engine.Connection) -> list[dict]: + """读取旧 systemconfig 单键里的实例描述,兼容历史字典与列表两种载荷形态。 + + :param connection: 当前迁移事务连接 + :return: 已补全 ``instance_id`` 的实例字典列表,损坏项直接跳过 + """ + systemconfig = sa.table( + "systemconfig", + sa.column("key", sa.String()), + sa.column("value", sa.JSON()), + ) + row = connection.execute( + sa.select(systemconfig.c.value).where(systemconfig.c.key == _LEGACY_KEY) + ).first() + raw = row[0] if row else None + if isinstance(raw, dict): + entries = [] + for instance_id, payload in raw.items(): + if not isinstance(payload, dict) or not instance_id: + continue + merged = dict(payload) + merged.setdefault("instance_id", instance_id) + entries.append(merged) + return entries + if isinstance(raw, list): + return [ + item + for item in raw + if isinstance(item, dict) and item.get("instance_id") + ] + return [] + + +def upgrade() -> None: + """建立插件实例描述符表,并把旧 systemconfig 单键的现有内容逐条搬入。 + + 只搬迁,不删除原 systemconfig 键:原键留作回滚依据,运行期兜底导入也据此 + 在表为空而旧键非空时补一次导入。 + """ + inspector = sa.inspect(op.get_bind()) + if _TABLE in inspector.get_table_names(): + return + op.create_table( + _TABLE, + _id_column(op.get_bind().dialect.name), + sa.Column("instance_id", sa.String(length=128), nullable=False), + sa.Column("source_plugin_id", sa.String(length=128), nullable=False), + sa.Column("plugin_name", sa.String(length=255), nullable=True), + sa.Column("plugin_desc", sa.String(length=255), nullable=True), + sa.Column("plugin_icon", sa.String(length=255), nullable=True), + sa.Column("mode", sa.String(length=16), nullable=False, server_default="virtual"), + sa.Column("plugin_version", sa.String(length=64), nullable=True), + sa.Column( + "follow_current_version", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ), + sa.Column("created_at", sa.String(length=40), nullable=False), + sa.Column("updated_at", sa.String(length=40), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("instance_id", name="uq_plugininstance_instance_id"), + sa.CheckConstraint( + "mode IN ('virtual', 'host')", + name="ck_plugininstance_mode", + ), + ) + op.create_index( + "ix_plugininstance_source_plugin_id", + _TABLE, + ["source_plugin_id"], + ) + + connection = op.get_bind() + entries = _legacy_entries(connection) + if not entries: + return + table = sa.table( + _TABLE, + sa.column("instance_id", sa.String()), + sa.column("source_plugin_id", sa.String()), + sa.column("plugin_name", sa.String()), + sa.column("plugin_desc", sa.String()), + sa.column("plugin_icon", sa.String()), + sa.column("mode", sa.String()), + sa.column("plugin_version", sa.String()), + sa.column("follow_current_version", sa.Boolean()), + sa.column("created_at", sa.String()), + sa.column("updated_at", sa.String()), + ) + now = datetime.now(timezone.utc).isoformat() + rows = [] + seen_instance_ids: set[str] = set() + for entry in entries: + instance_id = entry.get("instance_id") + source_plugin_id = entry.get("source_plugin_id") + if not instance_id or not source_plugin_id or instance_id in seen_instance_ids: + continue + seen_instance_ids.add(instance_id) + rows.append({ + "instance_id": instance_id, + "source_plugin_id": source_plugin_id, + "plugin_name": entry.get("plugin_name"), + "plugin_desc": entry.get("plugin_desc"), + "plugin_icon": entry.get("plugin_icon"), + "mode": "virtual", + "plugin_version": entry.get("plugin_version"), + "follow_current_version": bool(entry.get("follow_current_version", True)), + "created_at": now, + "updated_at": now, + }) + if rows: + connection.execute(table.insert(), rows) + + +def downgrade() -> None: + """删除插件实例描述符表,不触碰原 systemconfig 单键。""" + inspector = sa.inspect(op.get_bind()) + if _TABLE not in inspector.get_table_names(): + return + op.drop_index("ix_plugininstance_source_plugin_id", table_name=_TABLE) + op.drop_table(_TABLE) diff --git a/database/versions/487f7e681955_3_0_30.py b/database/versions/487f7e681955_3_0_30.py new file mode 100644 index 0000000000..3420ad32e6 --- /dev/null +++ b/database/versions/487f7e681955_3_0_30.py @@ -0,0 +1,39 @@ +"""3.0.30 插件实例描述符增加日志等级覆盖。 + +Revision ID: 487f7e681955 +Revises: 281965691a20 +Create Date: 2026-09-02 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "487f7e681955" +down_revision = "281965691a20" +branch_labels = None +depends_on = None + +_TABLE = "plugininstance" + + +def _column_names() -> set[str]: + """读取当前表已有列名,兼容重复升级和已由 create_all 建出当前模型的场景。""" + return {column["name"] for column in sa.inspect(op.get_bind()).get_columns(_TABLE)} + + +def upgrade() -> None: + """增加日志等级覆盖列,缺省为空即跟随全局等级。""" + columns = _column_names() + if "log_level" not in columns: + op.add_column(_TABLE, sa.Column("log_level", sa.String(length=16), nullable=True)) + if "log_expires_at" not in columns: + op.add_column(_TABLE, sa.Column("log_expires_at", sa.String(length=40), nullable=True)) + + +def downgrade() -> None: + """删除日志等级覆盖列。""" + columns = _column_names() + if "log_expires_at" in columns: + op.drop_column(_TABLE, "log_expires_at") + if "log_level" in columns: + op.drop_column(_TABLE, "log_level") diff --git a/database/versions/e0e68cbd5756_3_0_31.py b/database/versions/e0e68cbd5756_3_0_31.py new file mode 100644 index 0000000000..0634403d02 --- /dev/null +++ b/database/versions/e0e68cbd5756_3_0_31.py @@ -0,0 +1,64 @@ +"""3.0.31 插件实例描述符增加默认调用目标标记。 + +Revision ID: e0e68cbd5756 +Revises: 487f7e681955 +Create Date: 2026-09-03 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "e0e68cbd5756" +down_revision = "487f7e681955" +branch_labels = None +depends_on = None + +_TABLE = "plugininstance" +_DEFAULT_TARGET_INDEX = "ux_plugininstance_default_target" + + +def _column_names() -> set[str]: + """读取当前表已有列名,兼容重复升级和已由 create_all 建出当前模型的场景。""" + return {column["name"] for column in sa.inspect(op.get_bind()).get_columns(_TABLE)} + + +def _index_names() -> set[str]: + """读取当前表已有索引名,兼容重复升级和已由 create_all 建出当前模型的场景。""" + return {index["name"] for index in sa.inspect(op.get_bind()).get_indexes(_TABLE)} + + +def upgrade() -> None: + """增加默认调用目标标记列,并建出「同一源插件至多一个默认调用目标」的条件唯一索引。 + + 条件谓词按方言分别给出:布尔列与 ``True`` 比较,SQLite 编译为 ``IS 1``, + PostgreSQL 编译为 ``IS true``;谓词整个丢失会退化成「每个源插件只能有一行 + 实例」,把插件分身整个锁死,因此必须两个方言各给一份,不能只给一份共用。 + """ + columns = _column_names() + if "is_default_target" not in columns: + op.add_column( + _TABLE, + sa.Column( + "is_default_target", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + if _DEFAULT_TARGET_INDEX not in _index_names(): + op.create_index( + _DEFAULT_TARGET_INDEX, + _TABLE, + ["source_plugin_id"], + unique=True, + sqlite_where=sa.column("is_default_target", sa.Boolean()).is_(True), + postgresql_where=sa.column("is_default_target", sa.Boolean()).is_(True), + ) + + +def downgrade() -> None: + """删除条件唯一索引与默认调用目标标记列。""" + if _DEFAULT_TARGET_INDEX in _index_names(): + op.drop_index(_DEFAULT_TARGET_INDEX, table_name=_TABLE) + if "is_default_target" in _column_names(): + op.drop_column(_TABLE, "is_default_target") diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 9bd2e4a76e..6b70f2d01f 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -754,8 +754,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 968 | -| 内部导入边 | 8,127 | +| Python 模块 | 976 | +| 内部导入边 | 8,188 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/agent-api-surface-audit.json b/docs/architecture/agent-api-surface-audit.json index 7610d4baab..66c03af163 100644 --- a/docs/architecture/agent-api-surface-audit.json +++ b/docs/architecture/agent-api-surface-audit.json @@ -2,7 +2,7 @@ "disposition_counts": { "alternate-auth-duplicate": 11, "consolidated": 72, - "gateway": 199, + "gateway": 207, "provider-skill": 11, "stream_or_binary": 10, "transport_or_identity": 66, @@ -18,10 +18,10 @@ "reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route." } ], - "gateway_http_route_count": 200, - "gateway_operation_count": 202, - "matched_gateway_http_route_count": 199, - "openapi_operation_count": 385, + "gateway_http_route_count": 208, + "gateway_operation_count": 210, + "matched_gateway_http_route_count": 207, + "openapi_operation_count": 393, "operations": [ { "disposition": "consolidated", @@ -2135,6 +2135,76 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "DELETE", + "operation_ids": [ + "plugin.default_target.clear" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "清除插件实例的默认调用目标", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "PUT", + "operation_ids": [ + "plugin.default_target.set" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "设置插件实例的默认调用目标", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "GET", + "operation_ids": [ + "plugin.loglevel.get" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/loglevel/{plugin_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "查询插件全部实例的日志等级设置", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "DELETE", + "operation_ids": [ + "plugin.loglevel.clear" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "清除插件实例的日志等级覆盖", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "PUT", + "operation_ids": [ + "plugin.loglevel.set" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "设置插件实例的日志等级覆盖", + "tags": [ + "plugin" + ] + }, { "disposition": "ui_presentation", "method": "GET", @@ -2367,6 +2437,48 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "GET", + "operation_ids": [ + "plugin.versions.get" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/versions/{plugin_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "查询插件已装版本与实例版本绑定", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "POST", + "operation_ids": [ + "plugin.versions.recycle" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/versions/{plugin_id}/recycle", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "回收插件不再引用的已装版本目录", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "PUT", + "operation_ids": [ + "plugin.versions.set_instance" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/versions/{plugin_id}/{instance_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "设置插件实例的版本绑定", + "tags": [ + "plugin" + ] + }, { "disposition": "gateway", "method": "DELETE", diff --git a/docs/architecture/agent-api-surface-audit.md b/docs/architecture/agent-api-surface-audit.md index d0a49f10b2..64376f3d73 100644 --- a/docs/architecture/agent-api-surface-audit.md +++ b/docs/architecture/agent-api-surface-audit.md @@ -5,10 +5,10 @@ ## Result -- OpenAPI HTTP operations: **385** -- Stable `moviepilot_api` operations: **202** -- Exact HTTP routes used by the gateway: **200** -- OpenAPI routes matched directly by the gateway: **199** +- OpenAPI HTTP operations: **393** +- Stable `moviepilot_api` operations: **210** +- Exact HTTP routes used by the gateway: **208** +- OpenAPI routes matched directly by the gateway: **207** - Bounded dynamic gateway routes: **1** - Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`. - Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input. @@ -19,7 +19,7 @@ | :--- | ---: | :--- | | `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. | | `consolidated` | 72 | Source/UI route represented by a stable aggregate Agent operation. | -| `gateway` | 199 | Approved structured MoviePilot Agent operation. | +| `gateway` | 207 | Approved structured MoviePilot Agent operation. | | `provider-skill` | 11 | Low-level downloader or media-server capability owned by a provider Skill. | | `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. | | `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. | @@ -201,6 +201,11 @@ | `GET` | `/api/v1/plugin/history/{plugin_id}` | plugin | `gateway` | plugin.history | 获取插件更新说明 | | `GET` | `/api/v1/plugin/install/{plugin_id}` | plugin | `gateway` | plugin.install | 安装插件 | | `GET` | `/api/v1/plugin/installed` | plugin | `consolidated` | plugin.installed | 已安装插件 | +| `DELETE` | `/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target` | plugin | `gateway` | plugin.default_target.clear | 清除插件实例的默认调用目标 | +| `PUT` | `/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target` | plugin | `gateway` | plugin.default_target.set | 设置插件实例的默认调用目标 | +| `GET` | `/api/v1/plugin/loglevel/{plugin_id}` | plugin | `gateway` | plugin.loglevel.get | 查询插件全部实例的日志等级设置 | +| `DELETE` | `/api/v1/plugin/loglevel/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.loglevel.clear | 清除插件实例的日志等级覆盖 | +| `PUT` | `/api/v1/plugin/loglevel/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.loglevel.set | 设置插件实例的日志等级覆盖 | | `GET` | `/api/v1/plugin/page/{plugin_id}` | plugin | `ui_presentation` | host-ui | 获取插件数据页面 | | `GET` | `/api/v1/plugin/rating` | plugin | `gateway` | plugin.ratings | 批量查询插件评分 | | `GET` | `/api/v1/plugin/rating/{plugin_id}` | plugin | `gateway` | plugin.rating | 查询插件评分 | @@ -218,6 +223,9 @@ | `POST` | `/api/v1/plugin/source/{plugin_id}/install` | plugin | `gateway` | plugin.source.install | 按明确来源安装插件 | | `GET` | `/api/v1/plugin/source/{plugin_id}/options` | plugin | `consolidated` | plugin.source.options | 获取插件来源候选 | | `GET` | `/api/v1/plugin/statistic` | plugin | `gateway` | plugin.statistics | 插件安装统计 | +| `GET` | `/api/v1/plugin/versions/{plugin_id}` | plugin | `gateway` | plugin.versions.get | 查询插件已装版本与实例版本绑定 | +| `POST` | `/api/v1/plugin/versions/{plugin_id}/recycle` | plugin | `gateway` | plugin.versions.recycle | 回收插件不再引用的已装版本目录 | +| `PUT` | `/api/v1/plugin/versions/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.versions.set_instance | 设置插件实例的版本绑定 | | `DELETE` | `/api/v1/plugin/{plugin_id}` | plugin | `gateway` | plugin.uninstall | 卸载插件 | | `GET` | `/api/v1/plugin/{plugin_id}` | plugin | `consolidated` | plugin.config.get | 获取插件配置 | | `PUT` | `/api/v1/plugin/{plugin_id}` | plugin | `gateway` | plugin.config.update | 更新插件配置 | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index b7f588a527..71575244d0 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 968 / 8,127 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 976 / 8,188 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -103,7 +103,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 全量 mypy 历史债务 | 9,538 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 549 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| Ruff 历史诊断 | 548 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 797c73d3d6..c79de394e3 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -86,6 +86,14 @@ "Diagnosing interrupted installations, rollback conditions, and package or backup presence.", "Owned by the plugin installation state machine; never advance phase or overwrite evidence manually.", ), + "plugininstance": ( + "Stores one row per shared-source plugin runtime instance: virtual clone descriptors and the host " + "plugin's own version binding, plus each instance's log-level override and its expiry, plus which " + "instance (if any) is the plugin's default call target for unspecified-instance invocations.", + "Diagnosing clone naming, version binding, which instance currently overrides the global log level, " + "or which instance an unspecified-instance call would resolve to.", + "Owned by plugin instance, log-level control, and default-call-target control APIs; never edit rows directly.", + ), "site": ( "Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding.", "Inspecting enablement, domain, rate limits, or downloader binding with minimal credential exposure.", diff --git a/scripts/perf/module_shutdown_ab.py b/scripts/perf/module_shutdown_ab.py index f3c0e47252..d1bc591be3 100644 --- a/scripts/perf/module_shutdown_ab.py +++ b/scripts/perf/module_shutdown_ab.py @@ -118,7 +118,7 @@ async def run_samples( await sample( lambda: lifecycle.run_shutdown_step( "probe.sync_owner", - lifecycle.offload_shutdown_callback( + lifecycle.offload_blocking_callback( lambda: time.sleep(block_seconds) ), timeout_seconds=max(1.0, block_seconds * 4), diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index 7412f9236e..86d30f9461 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -156,7 +156,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores media identity, torrent, downloader, user, and recognition context for submitted downloads. - Useful queries: Reviewing download history or tracing a media identity or hash back to its source. - Write boundary: Written by the download use case; delete or correct records through the download-history API. -- Columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `image`, `poster`, `downloader`, `download_hash`, `torrent_name`, `torrent_description`, `torrent_site`, `userid`, `username`, `channel`, `date`, `note`, `media_category`, `episode_group`, `custom_words` +- Columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `image`, `poster`, `downloader`, `download_hash`, `torrent_name`, `torrent_description`, `torrent_site`, `userid`, `username`, `channel`, `date`, `note`, `media_category_id`, `media_category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `episode_group`, `custom_words` ### `mediaserveritem` - Purpose: Stores the local index and canonical media identity projected from media-server libraries. @@ -200,6 +200,12 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Write boundary: Owned by the plugin installation state machine; never advance phase or overwrite evidence manually. - Columns: `id`, `transaction_id`, `plugin_id`, `phase`, `membership_before`, `membership_target`, `identity_before_revision`, `identity_target_revision`, `package_existed`, `persistent_backup_existed`, `created_at`, `updated_at`, `schema_version` +### `plugininstance` +- Purpose: Stores one row per shared-source plugin runtime instance: virtual clone descriptors and the host plugin's own version binding, plus each instance's log-level override and its expiry, plus which instance (if any) is the plugin's default call target for unspecified-instance invocations. +- Useful queries: Diagnosing clone naming, version binding, which instance currently overrides the global log level, or which instance an unspecified-instance call would resolve to. +- Write boundary: Owned by plugin instance, log-level control, and default-call-target control APIs; never edit rows directly. +- Columns: `id`, `instance_id`, `source_plugin_id`, `plugin_name`, `plugin_desc`, `plugin_icon`, `mode`, `plugin_version`, `follow_current_version`, `log_level`, `log_expires_at`, `is_default_target`, `created_at`, `updated_at` + ### `site` - Purpose: Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding. - Useful queries: Inspecting enablement, domain, rate limits, or downloader binding with minimal credential exposure. @@ -228,13 +234,13 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores active movie, TV, or music subscriptions, filters, progress, and download targets. - Useful queries: Inspecting state, missing episodes/tracks, quality rules, site scope, and match progress. - Write boundary: Create, update, search, or delete through the subscription API to preserve state-machine consistency. -- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category`, `filter_groups`, `episode_group` +- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `lack_episode`, `note`, `state`, `last_update`, `date`, `username`, `sites`, `downloader`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `manual_total_episode`, `custom_words`, `media_category_id`, `media_category`, `filter_groups`, `episode_group` ### `subscribehistory` - Purpose: Stores snapshots of completed or archived subscriptions and their final filter state. - Useful queries: Auditing historical subscriptions, media identity, completion criteria, and filter configuration. - Write boundary: Generated by subscription completion and archival; restore or delete through its business API. -- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category`, `filter_groups`, `episode_group` +- Columns: `id`, `name`, `year`, `type`, `keyword`, `media_source`, `media_id`, `music_type`, `total_tracks`, `season`, `poster`, `backdrop`, `vote`, `description`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `total_episode`, `start_episode`, `date`, `username`, `sites`, `best_version`, `best_version_full`, `current_priority`, `current_audio_format`, `current_bitrate`, `current_bit_depth`, `current_sample_rate`, `episode_priority`, `save_path`, `search_imdbid`, `custom_words`, `media_category_id`, `media_category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `filter_groups`, `episode_group` ### `subscriptionsearchbatch` - Purpose: Stores durable subscription search batches, source, aggregate state, counts, and cancellation requests. @@ -270,7 +276,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores transfer source, destination, mode, media identity, download linkage, and outcome. - Useful queries: Reviewing success/failure history, destination paths, media classification, and download linkage. - Write boundary: Written by transfer settlement; delete or retry through transfer-history business APIs. -- Columns: `id`, `transfer_task_id`, `transfer_settlement_revision`, `src`, `src_storage`, `src_fileitem`, `dest`, `dest_storage`, `dest_fileitem`, `mode`, `type`, `category`, `title`, `year`, `media_source`, `media_id`, `music_type`, `total_tracks`, `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, `bitrate`, `seasons`, `episodes`, `image`, `downloader`, `download_hash`, `status`, `errmsg`, `date`, `files`, `episode_group` +- Columns: `id`, `transfer_task_id`, `transfer_settlement_revision`, `src`, `src_storage`, `src_fileitem`, `dest`, `dest_storage`, `dest_fileitem`, `mode`, `type`, `media_category_id`, `category`, `classification_rule_id`, `classification_policy_revision`, `classification_source`, `title`, `year`, `media_source`, `media_id`, `music_type`, `total_tracks`, `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, `bitrate`, `seasons`, `episodes`, `image`, `downloader`, `download_hash`, `status`, `errmsg`, `date`, `files`, `episode_group` ### `transferpending` - Purpose: Durably stores pending transfer input, plans, checkpoints, leases, retries, and manual review state. diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 8825f24cd1..367ea95ada 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -29,10 +29,10 @@ allowed-api-operations: >- system.update.install system.upgrade.dev dashboard.media.statistics dashboard.storage dashboard.processes dashboard.system dashboard.downloader scheduler.progress dashboard.transfer.statistics dashboard.cpu dashboard.memory dashboard.network media.sources - media.recognize_file media.category.config.get media.category.config.update media.categories - media.episode_groups media.episode_group.seasons media.seasons search.title search.recommend - subtitle.search.title subtitle.search.media site.add site.delete site.auth.options - site.authenticate site.cookiecloud.sync site.reset site.priorities.update site.userdata.refresh + media.recognize_file media.category.config.get media.categories media.episode_groups + media.episode_group.seasons media.seasons search.title search.recommend subtitle.search.title + subtitle.search.media site.add site.delete site.auth.options site.authenticate + site.cookiecloud.sync site.reset site.priorities.update site.userdata.refresh site.userdata.latest site.category site.resource site.searchable site.rss site.statistics site.statistic site.mapping site.supporting subscription.get subscription.find subscription.delete_by_media subscription.status.update subscription.reset @@ -53,7 +53,9 @@ allowed-api-operations: >- plugin.market.sync_wiki plugin.runtime.status plugin.history plugin.releases plugin.ratings plugin.rating plugin.rating.submit plugin.statistics plugin.reset plugin.clone config.user.get config.public.get system.usage.statistics plugin.folders.get plugin.folders.update - plugin.folder.create plugin.folder.delete plugin.folder.plugins.update + plugin.folder.create plugin.folder.delete plugin.folder.plugins.update plugin.versions.get + plugin.versions.set_instance plugin.versions.recycle plugin.loglevel.get plugin.loglevel.set + plugin.loglevel.clear plugin.default_target.set plugin.default_target.clear --- # MoviePilot API @@ -328,7 +330,7 @@ Purpose: List enabled downloader instance names and provider types without crede Purpose: Delete one MoviePilot download-history record. - `path_params`: none - `query`: none -- `body`: `channel` (string|null): Message channel that originally submitted the download.; `date` (string|null): Record creation or completion timestamp used by the history item.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `note` (JsonData-Input|null): Structured auxiliary metadata stored with the record.; `path` (string|null): Storage or history path represented by this record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `seasons` (string|null): Season-number expression recorded in history.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `torrent_description` (string|null): Torrent release description recorded in download history.; `torrent_name` (string|null): Torrent release name recorded in download history.; `torrent_site` (string|null): Source site name recorded in download history.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `userid` (string|null): Message-channel user ID recorded with download history.; `username` (string|null): MoviePilot or site username required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `channel` (string|null): Message channel that originally submitted the download.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `date` (string|null): Record creation or completion timestamp used by the history item.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `note` (JsonData-Input|null): Structured auxiliary metadata stored with the record.; `path` (string|null): Storage or history path represented by this record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `seasons` (string|null): Season-number expression recorded in history.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `torrent_description` (string|null): Torrent release description recorded in download history.; `torrent_name` (string|null): Torrent release name recorded in download history.; `torrent_site` (string|null): Source site name recorded in download history.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `userid` (string|null): Message-channel user ID recorded with download history.; `username` (string|null): MoviePilot or site username required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `download.history.list` `GET /api/v1/history/download`; policy effect: `safe_read`. @@ -453,13 +455,6 @@ Purpose: Read the complete automatic media-category strategy configuration. - `query`: none - `body`: none -### `media.category.config.update` -`POST /api/v1/media/category/config`; policy effect: `reversible_write`. -Purpose: Replace the complete automatic media-category strategy configuration. -- `path_params`: none -- `query`: none -- `body`: `movie` (object|null; default `{}`): Automatic movie-category rules evaluated in order.; `tv` (object|null; default `{}`): Automatic TV-category rules evaluated in order. - ### `media.detail` `GET /api/v1/media/{media_id}`; policy effect: `safe_read`. Purpose: Read canonical media details from one selected metadata source. @@ -661,6 +656,20 @@ Purpose: Read a bounded preview of one plugin's persisted data. - `query`: `key` (string|null): Optional exact plugin data key used to narrow the returned preview.; `max_chars` (integer|null): Maximum number of serialized plugin-data characters to return. - `body`: none +### `plugin.default_target.clear` +`DELETE /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target`; policy effect: `reversible_write`. +Purpose: Clear one plugin instance's default-call-target flag, only if it is the plugin's current default. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.versions.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.default_target.set` +`PUT /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target`; policy effect: `reversible_write`. +Purpose: Set one plugin instance as the plugin's default call target, automatically clearing any previous default. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.versions.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + ### `plugin.folder.create` `POST /api/v1/plugin/folders/{folder_name}`; policy effect: `reversible_write`. Purpose: Create one named plugin folder. @@ -718,6 +727,27 @@ Purpose: List installed plugins and their runtime status. - `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. - `body`: none +### `plugin.loglevel.clear` +`DELETE /api/v1/plugin/loglevel/{plugin_id}/{instance_id}`; policy effect: `reversible_write`. +Purpose: Clear one plugin instance's log-level override so it immediately follows the global log level again. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.versions.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.loglevel.get` +`GET /api/v1/plugin/loglevel/{plugin_id}`; policy effect: `safe_read`. +Purpose: List one plugin's instances, including its host binding, with each instance's configured and effective log level. +- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.loglevel.set` +`PUT /api/v1/plugin/loglevel/{plugin_id}/{instance_id}`; policy effect: `reversible_write`. +Purpose: Set one plugin instance's log-level override, taking effect immediately without following the global log level. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.versions.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: `expires_at` (string|null): Expiry timestamp for a plugin instance's log-level override; null means it never expires.; `level*` (string): Target log level, one of DEBUG, INFO, WARNING, ERROR, or CRITICAL. + ### `plugin.market` `GET /api/v1/plugin/`; policy effect: `safe_read`. Purpose: List plugins available from configured marketplaces. @@ -817,6 +847,27 @@ Purpose: Uninstall one plugin and remove it from the installed set. - `query`: none - `body`: none +### `plugin.versions.get` +`GET /api/v1/plugin/versions/{plugin_id}`; policy effect: `safe_read`. +Purpose: List one plugin's installed source versions and each instance's version binding. +- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.versions.recycle` +`POST /api/v1/plugin/versions/{plugin_id}/recycle`; policy effect: `external_side_effect`. +Purpose: Delete one plugin's installed source versions that are unreferenced and outside the retention window. +- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.versions.set_instance` +`PUT /api/v1/plugin/versions/{plugin_id}/{instance_id}`; policy effect: `external_side_effect`. +Purpose: Set one plugin instance's version binding and restart it to apply the change. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.versions.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: `follow_current_version*` (boolean): Follow the plugin's currently installed version instead of a pinned one.; `plugin_version` (string|null): Exact installed plugin version; required only when not following the current version. + ### `recommendation.list` `GET /api/v1/recommend/agent`; policy effect: `safe_read`. Purpose: Read personalized media or music recommendations. @@ -1074,7 +1125,7 @@ Purpose: List files or directories from one configured storage location. Purpose: Run one provider-defined management action against an exact configured storage target. - `path_params`: none - `query`: none -- `body`: `action*` (string): Exact provider or workflow action identifier required by the selected operation.; `params` (object): Provider-defined JSON parameters for the selected authentication or storage action.; `target*` (string): Exact configured storage target name accepted by storage.manage. +- `body`: `action*` (string): Exact provider or workflow action identifier required by the selected operation.; `params` (object): Provider-defined JSON parameters for the selected authentication or storage action.; `target*` (string): Exact target identifier selected by the operation. ### `storage.mkdir` `POST /api/v1/storage/mkdir`; policy effect: `reversible_write`. @@ -1103,7 +1154,7 @@ Purpose: Read configured directory or storage settings. Purpose: Create one movie, TV, or music subscription. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.delete` `DELETE /api/v1/subscribe/{subscribe_id}`; policy effect: `destructive_write`. @@ -1160,7 +1211,7 @@ Purpose: List subscription-sharing user IDs followed by the current user. Purpose: Create a local subscription from one shared subscription definition. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.get` `GET /api/v1/subscribe/{subscribe_id}`; policy effect: `safe_read`. @@ -1240,7 +1291,7 @@ Purpose: Start immediate searches for all subscriptions accessible to the curren Purpose: Publish one accessible subscription to the MoviePilot sharing service. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `count` (integer|null; default `0`): Maximum number of records to return on the requested page.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `share_comment` (string|null): Optional explanatory comment published with a shared item.; `share_title` (string|null): Public title used when publishing a subscription or workflow.; `share_uid` (string|null): Exact MoviePilot Server sharing-user ID to follow or unfollow.; `share_user` (string|null): Public contributor name used when publishing a subscription or workflow.; `subscribe_id` (integer|null): Persistent subscription ID returned by subscription.list.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.share.delete` `DELETE /api/v1/subscribe/share/{share_id}`; policy effect: `external_side_effect`. @@ -1277,7 +1328,7 @@ Purpose: Set one accessible subscription to running, paused, or stopped state. Purpose: Update one existing movie, TV, or music subscription. - `path_params`: none - `query`: none -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `subscription.user.list` `GET /api/v1/subscribe/user/{username}`; policy effect: `safe_read`. @@ -1341,28 +1392,28 @@ Purpose: Restart the running MoviePilot process. ### `system.update.check` `POST /api/v1/system/update/check`; policy effect: `external_side_effect`. -Purpose: Check GitHub for the latest stable MoviePilot v3 release. +Purpose: Check for the latest stable MoviePilot v3 application release and current-platform site resources. - `path_params`: none - `query`: none - `body`: none ### `system.update.download` `POST /api/v1/system/update/download`; policy effect: `external_side_effect`. -Purpose: Start downloading and verifying the available stable release in the background. +Purpose: Start downloading and verifying one selected application or site-resource update in the background. - `path_params`: none - `query`: none -- `body`: none +- `body` (SystemUpdateRequest|null): Request value for system.update.download. Start downloading and verifying one selected application or site-resource update in the background. Use the exact type and fields below. ### `system.update.install` `POST /api/v1/system/update/install`; policy effect: `external_side_effect`. -Purpose: Install the already downloaded and verified stable release, then restart MoviePilot. +Purpose: Install one selected already downloaded and verified application or site-resource update, then restart MoviePilot. - `path_params`: none - `query`: none -- `body`: none +- `body` (SystemUpdateRequest|null): Request value for system.update.install. Install one selected already downloaded and verified application or site-resource update, then restart MoviePilot. Use the exact type and fields below. ### `system.update.status` `GET /api/v1/system/update/status`; policy effect: `safe_read`. -Purpose: Read the current stable-release check, download, verification, or install state. +Purpose: Read application and site-resource update checks, downloads, verification, or install state. - `path_params`: none - `query`: none - `body`: none @@ -1458,7 +1509,7 @@ Purpose: Delete every transfer-history record while leaving transferred files un Purpose: Delete one transfer-history record and optionally remove files. - `path_params`: none - `query`: `deletedest` (boolean|null; default `False`): Also delete the organized destination files when deleting transfer history.; `deletesrc` (boolean|null; default `False`): Also delete the recorded source files when deleting transfer history. -- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_lossless` (boolean|null): Whether the recorded audio result is lossless.; `bit_depth` (integer|null): Recorded audio bit depth in bits.; `bitrate` (integer|null): Recorded audio bitrate in bits per second.; `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `date` (string|null): Record creation or completion timestamp used by the history item.; `dest` (string|null): Organized destination path recorded in transfer history.; `dest_fileitem` (JsonData-Input|null): Serialized destination storage item recorded by the transfer.; `dest_storage` (string|null): Configured storage name containing the organized destination.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `errmsg` (string|null): Error message recorded for a failed transfer.; `files` (JsonData-Input|null): Serialized list of files recorded by the history item.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `mode` (string|null): Operation mode; music.explore accepts chart or fresh, while transfer history records move, copy, link, or softlink.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `sample_rate` (integer|null): Recorded audio sample rate in hertz.; `seasons` (string|null): Season-number expression recorded in history.; `src` (string|null): Source path recorded in transfer history.; `src_fileitem` (JsonData-Input|null): Serialized source storage item recorded by the transfer.; `src_storage` (string|null): Configured storage name containing the transfer source.; `status` (boolean; default `True`): Transfer success status used to filter history or describe a record.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `transfer_task_id` (string|null): Stable durable transfer-task ID associated with the history record.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. +- `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_lossless` (boolean|null): Whether the recorded audio result is lossless.; `bit_depth` (integer|null): Recorded audio bit depth in bits.; `bitrate` (integer|null): Recorded audio bitrate in bits per second.; `category` (string|null): MoviePilot media category or filter-group category, depending on the operation.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `date` (string|null): Record creation or completion timestamp used by the history item.; `dest` (string|null): Organized destination path recorded in transfer history.; `dest_fileitem` (JsonData-Input|null): Serialized destination storage item recorded by the transfer.; `dest_storage` (string|null): Configured storage name containing the organized destination.; `download_hash` (string|null): Provider-native torrent hash associated with the record.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episodes` (string|null): Episode-number expression recorded in history, such as E01-E03.; `errmsg` (string|null): Error message recorded for a failed transfer.; `files` (JsonData-Input|null): Serialized list of files recorded by the history item.; `id*` (integer): Persistent database identifier of the supplied record.; `image` (string|null): Image URL stored with the history record.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `mode` (string|null): Operation mode; music.explore accepts chart or fresh, while transfer history records move, copy, link, or softlink.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `sample_rate` (integer|null): Recorded audio sample rate in hertz.; `seasons` (string|null): Season-number expression recorded in history.; `src` (string|null): Source path recorded in transfer history.; `src_fileitem` (JsonData-Input|null): Serialized source storage item recorded by the transfer.; `src_storage` (string|null): Configured storage name containing the transfer source.; `status` (boolean; default `True`): Transfer success status used to filter history or describe a record.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `transfer_task_id` (string|null): Stable durable transfer-task ID associated with the history record.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. ### `transfer.history.redo` `POST /api/v1/history/transfer/{history_id}/ai-redo`; policy effect: `external_side_effect`. diff --git a/tests/conftest.py b/tests/conftest.py index 0a22ff573b..84c1e47173 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -205,7 +205,9 @@ def configure_plugin_system_services(): from app.application.messaging.message import MessageHelper, MessageQueueManager from app.application.module import configure_module_runtime from app.application.plugin.runtime import configure_plugin_runtime + from app.db.oper.plugininstance import PluginInstanceOper from app.runtime.cache import AsyncFileCache, FileCache + from app.runtime.compat.readiness import plugin_multi_version_blockers from app.runtime.events import EventManager from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.runtime.extensions.module.manager import ModuleManager @@ -219,7 +221,10 @@ def configure_plugin_system_services(): PluginRuntimeEnvironment, build_plugin_runtime, ) - from app.runtime.extensions.plugin.storage import get_plugin_storage + from app.runtime.extensions.plugin.storage import ( + get_plugin_instance_directory, + get_plugin_storage, + ) from app.runtime.extensions.plugin.system import get_plugin_system from app.runtime.extensions.service import ServiceConfigHelper @@ -234,6 +239,7 @@ def build_test_plugin_runtime(host): PluginRuntimeEnvironment( plugins_root=settings.ROOT_PATH / "app" / "plugins", storage=get_plugin_storage, + instance_directory=get_plugin_instance_directory, system=get_plugin_system, database=get_plugin_database, catalog_factory=lambda mapper: ( @@ -251,6 +257,13 @@ def build_test_plugin_runtime(host): plugin_manager_module.get_runtime_setting('DEV') ), logger=plugin_manager_module.logger, + multi_version_blockers=plugin_multi_version_blockers, + set_default_target=lambda source_plugin_id, instance_id: ( + PluginInstanceOper().set_default_target(source_plugin_id, instance_id) + ), + clear_default_target=lambda source_plugin_id: ( + PluginInstanceOper().clear_default_target(source_plugin_id) + ), ), tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS, ) diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index cde0ca2e05..5260ee3d02 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -64,7 +64,9 @@ }, "app/adapters/system/plugin/package.py:app.runtime.execution.run_in_threadpool_to_completion": { "owners": { + "PluginPackageManager.__async_cleanup_failed_install": 1, "PluginPackageManager.__async_install_dependencies_if_required": 1, + "PluginPackageManager.__install_flow_async": 2, "PluginPackageManager.async_activate_persistent_backup": 1, "PluginPackageManager.async_checkpoint": 1, "PluginPackageManager.async_cleanup": 1, @@ -669,7 +671,7 @@ }, "app/startup/lifecycle/__init__.py:app.runtime.execution.run_in_threadpool_to_completion": { "owners": { - "offload_shutdown_callback.invoke": 1, + "offload_blocking_callback.invoke": 1, "prepare_plugin_restore": 1 }, "target": "app.runtime.execution.run_in_threadpool_to_completion" diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index eb9841daeb..36a10b5080 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8127, - "edge_sha256": "b355003c1ca55cd75ed454e9fe986dcf37b37078e6583563b41038c62a05c57f", + "edge_count": 8188, + "edge_sha256": "fd365175169e3c79fb9f2c33d267e52bcc0f1c6f02a296fe7510acd3544d3a15", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2527,6 +2527,7 @@ "app.api.endpoints.plugin -> app.runtime.extensions", "app.api.endpoints.plugin -> app.runtime.extensions.plugin", "app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts", + "app.api.endpoints.plugin -> app.runtime.extensions.plugin.version", "app.api.endpoints.plugin -> app.runtime.log", "app.api.endpoints.plugin -> app.runtime.tasks", "app.api.endpoints.plugin -> app.schemas", @@ -2539,6 +2540,18 @@ "app.api.endpoints.plugin -> app.startup", "app.api.endpoints.plugin -> app.startup.composition", "app.api.endpoints.plugin -> app.startup.composition.context", + "app.api.endpoints.pluginversion -> app.api", + "app.api.endpoints.pluginversion -> app.api.dependencies", + "app.api.endpoints.pluginversion -> app.api.dependencies.auth", + "app.api.endpoints.pluginversion -> app.api.principal", + "app.api.endpoints.pluginversion -> app.api.response", + "app.api.endpoints.pluginversion -> app.application", + "app.api.endpoints.pluginversion -> app.application.plugin", + "app.api.endpoints.pluginversion -> app.application.plugin.runtime", + "app.api.endpoints.pluginversion -> app.schemas", + "app.api.endpoints.pluginversion -> app.schemas.exception", + "app.api.endpoints.pluginversion -> app.schemas.plugin", + "app.api.endpoints.pluginversion -> app.schemas.response", "app.api.endpoints.recommend -> app.adapters", "app.api.endpoints.recommend -> app.adapters.web", "app.api.endpoints.recommend -> app.adapters.web.security", @@ -2911,6 +2924,7 @@ "app.api.routers -> app.api.endpoints.notification", "app.api.routers -> app.api.endpoints.openai", "app.api.routers -> app.api.endpoints.plugin", + "app.api.routers -> app.api.endpoints.pluginversion", "app.api.routers -> app.api.endpoints.recommend", "app.api.routers -> app.api.endpoints.rule", "app.api.routers -> app.api.endpoints.search", @@ -5517,6 +5531,8 @@ "app.db.models.pluginidentity -> app.db.base", "app.db.models.plugininstallation -> app.db", "app.db.models.plugininstallation -> app.db.base", + "app.db.models.plugininstance -> app.db", + "app.db.models.plugininstance -> app.db.base", "app.db.models.site -> app.db", "app.db.models.site -> app.db.base", "app.db.models.siteicon -> app.db", @@ -5608,6 +5624,10 @@ "app.db.oper.pluginidentity -> app.db.base", "app.db.oper.pluginidentity -> app.db.models", "app.db.oper.pluginidentity -> app.db.models.pluginidentity", + "app.db.oper.plugininstance -> app.db", + "app.db.oper.plugininstance -> app.db.base", + "app.db.oper.plugininstance -> app.db.models", + "app.db.oper.plugininstance -> app.db.models.plugininstance", "app.db.oper.query -> app.db", "app.db.oper.query -> app.db.base", "app.db.oper.query -> app.schemas", @@ -7662,6 +7682,9 @@ "app.runtime.compat.imports -> app.runtime.compat", "app.runtime.compat.imports -> app.runtime.compat.diagnostics", "app.runtime.compat.imports -> app.runtime.compat.manifest", + "app.runtime.compat.readiness -> app.runtime", + "app.runtime.compat.readiness -> app.runtime.compat", + "app.runtime.compat.readiness -> app.runtime.compat.resources", "app.runtime.config -> app.foundation", "app.runtime.config -> app.foundation.environment", "app.runtime.config -> app.foundation.url", @@ -7774,6 +7797,12 @@ "app.runtime.extensions.module.manager -> app.schemas.types", "app.runtime.extensions.plugin.admission -> app.schemas", "app.runtime.extensions.plugin.admission -> app.schemas.exception", + "app.runtime.extensions.plugin.binding -> app.runtime", + "app.runtime.extensions.plugin.binding -> app.runtime.extensions", + "app.runtime.extensions.plugin.binding -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.binding -> app.runtime.extensions.plugin.version", + "app.runtime.extensions.plugin.binding -> app.schemas", + "app.runtime.extensions.plugin.binding -> app.schemas.plugin", "app.runtime.extensions.plugin.catalog -> app.foundation", "app.runtime.extensions.plugin.catalog -> app.foundation.version", "app.runtime.extensions.plugin.catalog -> app.runtime", @@ -7782,6 +7811,7 @@ "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.contracts", "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.storage", "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.catalog -> app.runtime.log", "app.runtime.extensions.plugin.catalog -> app.runtime.settings", "app.runtime.extensions.plugin.catalog -> app.schemas", "app.runtime.extensions.plugin.catalog -> app.schemas.plugin", @@ -7809,15 +7839,23 @@ "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin.database", + "app.runtime.extensions.plugin.lifecycle -> app.runtime.log", "app.runtime.extensions.plugin.lifecycle -> app.runtime.observability", "app.runtime.extensions.plugin.lifecycle -> app.schemas", "app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin", "app.runtime.extensions.plugin.loader -> app.foundation", "app.runtime.extensions.plugin.loader -> app.foundation.environment", "app.runtime.extensions.plugin.loader -> app.runtime", + "app.runtime.extensions.plugin.loader -> app.runtime.extensions", + "app.runtime.extensions.plugin.loader -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.loader -> app.runtime.extensions.plugin.version", "app.runtime.extensions.plugin.loader -> app.runtime.settings", "app.runtime.extensions.plugin.loader -> app.schemas", "app.runtime.extensions.plugin.loader -> app.schemas.plugin", + "app.runtime.extensions.plugin.loglevel -> app.runtime", + "app.runtime.extensions.plugin.loglevel -> app.runtime.log", + "app.runtime.extensions.plugin.loglevel -> app.schemas", + "app.runtime.extensions.plugin.loglevel -> app.schemas.plugin", "app.runtime.extensions.plugin.manager -> app.foundation", "app.runtime.extensions.plugin.manager -> app.foundation.environment", "app.runtime.extensions.plugin.manager -> app.foundation.singleton", @@ -7853,6 +7891,9 @@ "app.runtime.extensions.plugin.paths -> app.runtime.extensions", "app.runtime.extensions.plugin.paths -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.paths -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.paths -> app.runtime.extensions.plugin.version", + "app.runtime.extensions.plugin.paths -> app.schemas", + "app.runtime.extensions.plugin.paths -> app.schemas.plugin", "app.runtime.extensions.plugin.projection -> app.runtime", "app.runtime.extensions.plugin.projection -> app.runtime.extensions", "app.runtime.extensions.plugin.projection -> app.runtime.extensions.plugin", @@ -7871,6 +7912,7 @@ "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.access", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.admission", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.binding", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.catalog", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.classification", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.clone", @@ -7879,6 +7921,7 @@ "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.dependency", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.lifecycle", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.loglevel", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.monitor", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.paths", @@ -7887,6 +7930,7 @@ "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.storage", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.sync", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.target", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.tools", "app.runtime.extensions.plugin.runtime -> app.schemas", "app.runtime.extensions.plugin.runtime -> app.schemas.types", @@ -7901,10 +7945,18 @@ "app.runtime.extensions.plugin.sync -> app.runtime.extensions", "app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.target -> app.schemas", + "app.runtime.extensions.plugin.target -> app.schemas.plugin", "app.runtime.extensions.plugin.tools -> app.runtime", "app.runtime.extensions.plugin.tools -> app.runtime.extensions", "app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin.version -> app.foundation", + "app.runtime.extensions.plugin.version -> app.foundation.version", + "app.runtime.extensions.plugin.version -> app.runtime", + "app.runtime.extensions.plugin.version -> app.runtime.log", + "app.runtime.extensions.plugin.version -> app.schemas", + "app.runtime.extensions.plugin.version -> app.schemas.plugin", "app.runtime.extensions.resource -> app.runtime", "app.runtime.extensions.resource -> app.runtime.capabilities", "app.runtime.extensions.resource -> app.runtime.capabilities.errors", @@ -8587,6 +8639,11 @@ "app.startup.composition.plugin -> app.adapters.system.plugin.health", "app.startup.composition.plugin -> app.adapters.system.plugin.package", "app.startup.composition.plugin -> app.runtime", + "app.startup.composition.plugin -> app.runtime.compat", + "app.startup.composition.plugin -> app.runtime.compat.readiness", + "app.startup.composition.plugin -> app.runtime.extensions", + "app.startup.composition.plugin -> app.runtime.extensions.plugin", + "app.startup.composition.plugin -> app.runtime.extensions.plugin.version", "app.startup.composition.plugin -> app.runtime.settings", "app.startup.composition.resource -> app.adapters", "app.startup.composition.resource -> app.adapters.system", @@ -8909,8 +8966,11 @@ "app.startup.initializers.plugins -> app.application.scheduling", "app.startup.initializers.plugins -> app.application.site", "app.startup.initializers.plugins -> app.db", + "app.startup.initializers.plugins -> app.db.models", + "app.startup.initializers.plugins -> app.db.models.plugininstance", "app.startup.initializers.plugins -> app.db.oper", "app.startup.initializers.plugins -> app.db.oper.plugindata", + "app.startup.initializers.plugins -> app.db.oper.plugininstance", "app.startup.initializers.plugins -> app.db.plugin", "app.startup.initializers.plugins -> app.db.plugin.registry", "app.startup.initializers.plugins -> app.db.session", @@ -8921,6 +8981,7 @@ "app.startup.initializers.plugins -> app.runtime.cache", "app.startup.initializers.plugins -> app.runtime.compat", "app.startup.initializers.plugins -> app.runtime.compat.diagnostics", + "app.startup.initializers.plugins -> app.runtime.compat.readiness", "app.startup.initializers.plugins -> app.runtime.compat.resources", "app.startup.initializers.plugins -> app.runtime.execution", "app.startup.initializers.plugins -> app.runtime.extensions", @@ -9220,7 +9281,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 968, + "module_count": 976, "modules": [ "app", "app.adapters", @@ -9391,6 +9452,7 @@ "app.api.endpoints.notification", "app.api.endpoints.openai", "app.api.endpoints.plugin", + "app.api.endpoints.pluginversion", "app.api.endpoints.recommend", "app.api.endpoints.rule", "app.api.endpoints.search", @@ -9700,6 +9762,7 @@ "app.db.models.plugindata", "app.db.models.pluginidentity", "app.db.models.plugininstallation", + "app.db.models.plugininstance", "app.db.models.site", "app.db.models.siteicon", "app.db.models.sitestatistic", @@ -9725,6 +9788,7 @@ "app.db.oper.passkey", "app.db.oper.plugindata", "app.db.oper.pluginidentity", + "app.db.oper.plugininstance", "app.db.oper.query", "app.db.oper.site", "app.db.oper.subscribe", @@ -9979,6 +10043,7 @@ "app.runtime.compat.diagnostics", "app.runtime.compat.imports", "app.runtime.compat.manifest", + "app.runtime.compat.readiness", "app.runtime.compat.resources", "app.runtime.config", "app.runtime.correlation", @@ -10008,6 +10073,7 @@ "app.runtime.extensions.plugin", "app.runtime.extensions.plugin.access", "app.runtime.extensions.plugin.admission", + "app.runtime.extensions.plugin.binding", "app.runtime.extensions.plugin.catalog", "app.runtime.extensions.plugin.classification", "app.runtime.extensions.plugin.clone", @@ -10016,6 +10082,7 @@ "app.runtime.extensions.plugin.dependency", "app.runtime.extensions.plugin.lifecycle", "app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin.loglevel", "app.runtime.extensions.plugin.manager", "app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin.monitor", @@ -10026,7 +10093,9 @@ "app.runtime.extensions.plugin.storage", "app.runtime.extensions.plugin.sync", "app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.target", "app.runtime.extensions.plugin.tools", + "app.runtime.extensions.plugin.version", "app.runtime.extensions.resource", "app.runtime.extensions.service", "app.runtime.gc", diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 8e3b3c6445..179470294f 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -498,9 +498,6 @@ "app/runtime/extensions/module/manager.py": { "I001": 1 }, - "app/runtime/extensions/plugin/loader.py": { - "I001": 1 - }, "app/runtime/extensions/plugin/storage.py": { "I001": 1 }, diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index b88e24580b..e81163e424 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 535, + "loaded_app_module_count": 542, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 547, + "loaded_app_module_count": 554, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 549, + "loaded_app_module_count": 556, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 9bdbde567d..97d948f42b 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -33,8 +33,8 @@ def test_api_operation_registry_matches_migration_batches() -> None: assert len(API_PARITY_OPERATION_SPECS) == 15 assert len(API_MUSIC_OPERATION_SPECS) == 10 assert len(API_SYSTEM_OPERATION_SPECS) == 7 - assert len(API_EXTENDED_OPERATION_SPECS) == 118 - assert len(API_OPERATION_SPECS) == 202 + assert len(API_EXTENDED_OPERATION_SPECS) == 126 + assert len(API_OPERATION_SPECS) == 210 assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES) assert { "download.list", diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index 769a536187..eb48b791b9 100644 --- a/tests/test_agent_skills_middleware.py +++ b/tests/test_agent_skills_middleware.py @@ -138,7 +138,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None: assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is False assert payload["truncation_message"] is None - assert len(payload["skill"]["allowed_api_operations"]) == 203 + assert len(payload["skill"]["allowed_api_operations"]) == 210 assert "### `workflow.update`" in payload["content"] diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index d2587faa09..b5b5f4c895 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -1478,7 +1478,7 @@ def blocking_shutdown() -> None: shutdown = asyncio.create_task( lifecycle.run_shutdown_step( "同步阻塞 owner", - lifecycle.offload_shutdown_callback(blocking_shutdown), + lifecycle.offload_blocking_callback(blocking_shutdown), timeout_seconds=0.02, ) ) diff --git a/tests/test_plugin_adapter_parity.py b/tests/test_plugin_adapter_parity.py index c3f09b9780..a949adb0a8 100644 --- a/tests/test_plugin_adapter_parity.py +++ b/tests/test_plugin_adapter_parity.py @@ -127,7 +127,7 @@ async def async_get_plugin_package_version(*_args) -> str | None: {"version": "1.2.3", "release": True}, None, [], - ["release", "remove", "filelist"], + ["release", "filelist"], ), ( {"version": "1.2.3", "release": True}, @@ -139,6 +139,7 @@ async def async_get_plugin_package_version(*_args) -> str | None: ) async def test_plugin_package_sync_async_execute_same_selected_strategy( monkeypatch, + tmp_path: Path, metadata: dict, release_version: str | None, release_items: list[dict], @@ -191,17 +192,11 @@ async def async_prepare_filelist(*_args) -> tuple[bool, str]: async_calls.append("filelist") return True, "installed" - def remove(*_args) -> None: - sync_calls.append("remove") - - async def async_remove(*_args) -> None: - async_calls.append("remove") - def install_flow(_pid, _force, prepare, _repo_url, _before): - return prepare() + return prepare(tmp_path / "staging") async def async_install_flow(_pid, _force, prepare, _repo_url, _before): - return await prepare() + return await prepare(tmp_path / "staging") monkeypatch.setattr(manager, "async_get_plugin_package_version", selected_version) monkeypatch.setattr(manager, "async_get_plugin_release_versions", releases) @@ -210,8 +205,6 @@ async def async_install_flow(_pid, _force, prepare, _repo_url, _before): monkeypatch.setattr(manager, "_PluginPackageManager__async_install_from_release", async_install_release) monkeypatch.setattr(manager, "_PluginPackageManager__prepare_content_via_filelist_sync", prepare_filelist) monkeypatch.setattr(manager, "_PluginPackageManager__prepare_content_via_filelist_async", async_prepare_filelist) - monkeypatch.setattr(manager, "_PluginPackageManager__remove_old_plugin", remove) - monkeypatch.setattr(manager, "_PluginPackageManager__async_remove_old_plugin", async_remove) monkeypatch.setattr(manager, "_PluginPackageManager__install_flow_sync", install_flow) monkeypatch.setattr(manager, "_PluginPackageManager__install_flow_async", async_install_flow) diff --git a/tests/test_plugin_catalog_runtime.py b/tests/test_plugin_catalog_runtime.py index a7f4e3b54a..dbb76c66d5 100644 --- a/tests/test_plugin_catalog_runtime.py +++ b/tests/test_plugin_catalog_runtime.py @@ -2,11 +2,44 @@ from types import SimpleNamespace +import pytest + +from app.runtime import log as log_module from app.runtime.extensions.plugin.catalog import PluginCatalogFacade -from app.schemas.plugin import PluginRuntimeStatus +from app.runtime.log import set_plugin_instance_log_level +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey +@pytest.fixture(name="_isolated_log_overrides", autouse=False) +def fixture_isolated_log_overrides(monkeypatch): + """隔离进程内日志等级覆盖缓存,避免与其他用例的实例 ID 相互污染。""" + monkeypatch.setattr(log_module, "_plugin_level_overrides", {}) + + +def _facade(**overrides): + """按测试所需覆盖最小可用回调集合,构造一个 PluginCatalogFacade。""" + defaults = dict( + classes=lambda: {}, + running=lambda: {}, + storage=lambda: SimpleNamespace(read=lambda _key: None), + system=lambda: SimpleNamespace(), + market_catalog=lambda: None, + market_loader=lambda *_args, **_kwargs: [], + async_market_loader=lambda *_args, **_kwargs: [], + map_plugin=lambda **_kwargs: None, + auth_checker=lambda **_kwargs: True, + plugin_attr=lambda _plugin_id, _attr: None, + plugin_instance=lambda _plugin_id: None, + plugin_instances=lambda: {}, + host_instances=lambda: {}, + runtime_status=lambda _plugin_id: None, + log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None), + ) + defaults.update(overrides) + return PluginCatalogFacade(**defaults) + + def test_installed_catalog_keeps_plugins_that_are_not_loaded(): """已安装清单中的插件即使缺依赖或源码也必须保留可观察卡片。""" class ActivePlugin: @@ -39,6 +72,7 @@ class ActivePlugin: plugin_attr=lambda _plugin_id, _attr: None, plugin_instance=lambda _plugin_id: None, plugin_instances=lambda: {}, + host_instances=lambda: {}, runtime_status=statuses.get, log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None), ) @@ -81,6 +115,7 @@ def local_candidates(): plugin_attr=lambda _plugin_id, _attr: None, plugin_instance=lambda _plugin_id: None, plugin_instances=lambda: {}, + host_instances=lambda: {}, runtime_status=lambda _plugin_id: None, log=SimpleNamespace( error=lambda *_args: None, @@ -93,3 +128,150 @@ def local_candidates(): assert warnings == [ "读取本地插件仓候选失败,已跳过本地目录展示:invalid local package" ] + + +def test_local_projects_pinned_version_default_target_and_log_level_for_virtual_instance( + _isolated_log_overrides, +): + """钉版本、默认调用目标与生效日志等级都要如实投影到分身实例的卡片上。""" + set_plugin_instance_log_level("CatalogOverlayVirtual", "DEBUG") + + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + instance = PluginInstance( + instance_id="CatalogOverlayVirtual", + source_plugin_id="DemoPlugin", + mode="virtual", + plugin_version="1.2.0", + follow_current_version=False, + is_default_target=True, + ) + facade = _facade( + classes=lambda: {"CatalogOverlayVirtual": DemoPlugin}, + plugin_instance=lambda plugin_id: ( + instance if plugin_id == "CatalogOverlayVirtual" else None + ), + plugin_instances=lambda: {"CatalogOverlayVirtual": instance}, + ) + + plugin = facade.local()[0] + + assert plugin.pinned_version == "1.2.0" + assert plugin.is_default_target is True + assert plugin.log_level_effective == "DEBUG" + + +def test_local_reports_no_pin_and_no_log_override_when_instance_follows_defaults( + _isolated_log_overrides, +): + """跟随当前版本、非默认目标、无日志覆盖的分身实例不应带任何叠加徽标信息。""" + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + instance = PluginInstance( + instance_id="CatalogOverlayDefaultVirtual", + source_plugin_id="DemoPlugin", + mode="virtual", + plugin_version="1.2.0", + follow_current_version=True, + is_default_target=False, + ) + facade = _facade( + classes=lambda: {"CatalogOverlayDefaultVirtual": DemoPlugin}, + plugin_instance=lambda plugin_id: ( + instance if plugin_id == "CatalogOverlayDefaultVirtual" else None + ), + plugin_instances=lambda: {"CatalogOverlayDefaultVirtual": instance}, + ) + + plugin = facade.local()[0] + + assert plugin.pinned_version is None + assert plugin.is_default_target is False + assert plugin.log_level_effective is None + + +def test_local_falls_back_to_host_binding_record_for_physical_plugin( + _isolated_log_overrides, +): + """物理插件没有分身记录时改用批量取到的本体绑定记录投影三个叠加字段。 + + ``is_instance``、``instance_mode`` 只看分身记录,不受本体绑定记录影响, + 现有语义保持不变。 + """ + set_plugin_instance_log_level("CatalogOverlayHost", "WARNING") + + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + host_instance = PluginInstance( + instance_id="CatalogOverlayHost", + source_plugin_id="CatalogOverlayHost", + mode="host", + plugin_version="2.0.0", + follow_current_version=False, + is_default_target=True, + ) + facade = _facade( + classes=lambda: {"CatalogOverlayHost": DemoPlugin}, + host_instances=lambda: {"CatalogOverlayHost": host_instance}, + ) + + plugin = facade.local()[0] + + assert plugin.is_instance is False + assert plugin.instance_mode is None + assert plugin.pinned_version == "2.0.0" + assert plugin.is_default_target is True + assert plugin.log_level_effective == "WARNING" + + +def test_local_defaults_overlay_fields_without_any_instance_record( + _isolated_log_overrides, +): + """既无分身也无本体绑定记录时,三个叠加字段要落到跟随全局的默认值。""" + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + facade = _facade(classes=lambda: {"CatalogOverlayNone": DemoPlugin}) + + plugin = facade.local()[0] + + assert plugin.pinned_version is None + assert plugin.is_default_target is False + assert plugin.log_level_effective is None + + +def test_installed_placeholder_projects_overlay_fields_from_host_binding_record( + _isolated_log_overrides, +): + """未加载插件的占位卡片同样要用批量取到的本体绑定记录投影叠加字段。""" + host_instance = PluginInstance( + instance_id="CatalogOverlayPlaceholder", + source_plugin_id="CatalogOverlayPlaceholder", + mode="host", + plugin_version="0.9.0", + follow_current_version=False, + is_default_target=False, + ) + facade = _facade( + storage=lambda: SimpleNamespace( + read=lambda key: ( + ["CatalogOverlayPlaceholder"] + if key is SystemConfigKey.UserInstalledPlugins + else None + ) + ), + host_instances=lambda: {"CatalogOverlayPlaceholder": host_instance}, + ) + + plugin = facade.installed()[0] + + assert plugin.pinned_version == "0.9.0" + assert plugin.is_default_target is False + assert plugin.log_level_effective is None diff --git a/tests/test_plugin_database_lifecycle.py b/tests/test_plugin_database_lifecycle.py index 06749ae204..f8728cf94f 100644 --- a/tests/test_plugin_database_lifecycle.py +++ b/tests/test_plugin_database_lifecycle.py @@ -96,7 +96,7 @@ def _build_lifecycle(**overrides: Any) -> PluginLifecycle: defaults: dict[str, Any] = dict( classes={}, running={}, - load_plugins=lambda _plugin_id, _installed, _check: [], + load_plugins=lambda _plugin_id, _installed, _check, _version=None: [], installed_plugins=lambda: [], plugin_config=lambda _plugin_id: {}, auth_checker=lambda _plugin: True, diff --git a/tests/test_plugin_default_target.py b/tests/test_plugin_default_target.py new file mode 100644 index 0000000000..52f1017730 --- /dev/null +++ b/tests/test_plugin_default_target.py @@ -0,0 +1,336 @@ +"""插件默认调用目标裁决与置位/清除测试。""" + +from __future__ import annotations + +import pytest + +from app.runtime.extensions.plugin.target import PluginDefaultTargetControl +from app.schemas.plugin import PluginInstance + + +class _Harness: + """组装 PluginDefaultTargetControl 依赖并记录调用轨迹的测试脚手架。""" + + def __init__( + self, + *, + host_instance: PluginInstance | None = None, + clones: list[PluginInstance] | None = None, + plugin_exists: bool = True, + running_ids: set[str] | None = None, + set_result: bool = True, + ) -> None: + self.host_instance = host_instance + self.clones = list(clones or []) + self.plugin_exists_flag = plugin_exists + self.running_ids = set(running_ids or set()) + self.saved_hosts: list[PluginInstance] = [] + self.set_calls: list[tuple[str, str]] = [] + self.clear_calls: list[str] = [] + self._set_result = set_result + + def _get_instance(self, instance_id: str) -> PluginInstance | None: + for clone in self.clones: + if clone.instance_id == instance_id: + return clone + return None + + def _instances_for_source(self, source_plugin_id: str) -> list[PluginInstance]: + return [clone for clone in self.clones if clone.source_plugin_id == source_plugin_id] + + def _get_host_instance(self, plugin_id: str) -> PluginInstance | None: + if self.host_instance is not None and self.host_instance.instance_id == plugin_id: + return self.host_instance + return None + + def _save_host_instance(self, instance: PluginInstance) -> None: + self.saved_hosts.append(instance) + self.host_instance = instance + + def _running(self) -> dict[str, object]: + return {instance_id: object() for instance_id in self.running_ids} + + def _set_default_target(self, plugin_id: str, instance_id: str) -> bool: + self.set_calls.append((plugin_id, instance_id)) + return self._set_result + + def _clear_default_target(self, plugin_id: str) -> None: + self.clear_calls.append(plugin_id) + + def build(self) -> PluginDefaultTargetControl: + """构造挂接本脚手架全部端口的裁决与置位控制器。""" + return PluginDefaultTargetControl( + plugin_exists=lambda _plugin_id: self.plugin_exists_flag, + get_instance=self._get_instance, + instances_for_source=self._instances_for_source, + get_host_instance=self._get_host_instance, + save_host_instance=self._save_host_instance, + running=self._running, + set_default_target=self._set_default_target, + clear_default_target=self._clear_default_target, + ) + + +def _clone(instance_id: str, source_plugin_id: str, *, default: bool = False) -> PluginInstance: + """构造一个分身实例描述。""" + return PluginInstance( + instance_id=instance_id, + source_plugin_id=source_plugin_id, + mode="virtual", + is_default_target=default, + ) + + +def _host(plugin_id: str, *, default: bool = False) -> PluginInstance: + """构造一个本体实例描述。""" + return PluginInstance( + instance_id=plugin_id, + source_plugin_id=plugin_id, + mode="host", + is_default_target=default, + ) + + +# --------------------------------------------------------------------------- # +# resolve():单实例场景与显式实例直通 +# --------------------------------------------------------------------------- # + + +def test_resolve_returns_plugin_id_when_no_clones_exist(): + """只有本体、没有任何分身时直接返回插件 ID,不要求设置默认目标。""" + harness = _Harness(host_instance=_host("PluginA"), running_ids={"PluginA"}) + + assert harness.build().resolve("PluginA") == "PluginA" + + +def test_resolve_returns_argument_unchanged_when_it_is_already_a_clone_id(): + """传入的标识本身就是某个分身的实例 ID 时原样返回,该分身没有自己的下级分身。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginAx2", "PluginA")], + running_ids={"PluginAx2"}, + ) + + assert harness.build().resolve("PluginAx2") == "PluginAx2" + + +# --------------------------------------------------------------------------- # +# resolve():有分身时的默认目标裁决 +# --------------------------------------------------------------------------- # + + +def test_resolve_uses_enabled_default_target_among_clones(): + """已有分身且默认目标已启用时,未指定实例的调用落到该默认目标。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[ + _clone("PluginAx2", "PluginA", default=True), + _clone("PluginAx3", "PluginA"), + ], + running_ids={"PluginAx2", "PluginAx3"}, + ) + + assert harness.build().resolve("PluginA") == "PluginAx2" + + +def test_resolve_host_itself_can_be_default_target(): + """本体同样可以被选为默认调用目标,与分身地位相同。""" + harness = _Harness( + host_instance=_host("PluginA", default=True), + clones=[_clone("PluginAx2", "PluginA")], + running_ids={"PluginA", "PluginAx2"}, + ) + + assert harness.build().resolve("PluginA") == "PluginA" + + +def test_resolve_raises_when_no_default_target_set(): + """已有分身但未设置默认目标时报错,且不得回退到任何一个候选。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginAx2", "PluginA"), _clone("PluginAx3", "PluginA")], + running_ids={"PluginA", "PluginAx2", "PluginAx3"}, + ) + + with pytest.raises(LookupError) as excinfo: + harness.build().resolve("PluginA") + + message = str(excinfo.value) + assert "PluginA" in message + assert "未设置默认实例" in message + assert "PluginA(已启用)" in message + assert "PluginAx2(已启用)" in message + assert "PluginAx3(已启用)" in message + + +def test_resolve_raises_when_default_target_disabled_and_does_not_fall_back(): + """默认目标已停用时必须报错,不得静默改走另一个正在运行的实例。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[ + _clone("PluginAx2", "PluginA", default=True), + _clone("PluginAx3", "PluginA"), + ], + running_ids={"PluginAx3"}, + ) + + with pytest.raises(LookupError) as excinfo: + harness.build().resolve("PluginA") + + message = str(excinfo.value) + assert "默认实例 PluginAx2 已停用" in message + assert "PluginAx3(已启用)" in message + + +def test_resolve_candidate_description_orders_alphabetically(): + """候选实例描述按实例 ID 升序排列,报错文案稳定可预期。""" + harness = _Harness( + host_instance=_host("PluginZ"), + clones=[_clone("PluginZb", "PluginZ"), _clone("PluginZa", "PluginZ")], + running_ids=set(), + ) + + with pytest.raises(LookupError) as excinfo: + harness.build().resolve("PluginZ") + + assert str(excinfo.value).endswith( + "可选实例:PluginZ(已停用)、PluginZa(已停用)、PluginZb(已停用)" + ) + + +def test_resolve_treats_never_persisted_host_as_a_candidate(): + """本体从未被显式绑定过任何设置时,仍以默认视图参与候选而不是被略去。""" + harness = _Harness( + host_instance=None, + clones=[_clone("PluginAx2", "PluginA")], + running_ids={"PluginA"}, + ) + + with pytest.raises(LookupError) as excinfo: + harness.build().resolve("PluginA") + + assert "PluginA(已启用)" in str(excinfo.value) + + +# --------------------------------------------------------------------------- # +# set_target() +# --------------------------------------------------------------------------- # + + +def test_set_target_upserts_never_persisted_host_before_setting(): + """本体从未落盘过时,设为默认目标前先落盘一条默认视图记录。""" + harness = _Harness(host_instance=None) + + result = harness.build().set_target("PluginA", "PluginA") + + assert result is True + assert len(harness.saved_hosts) == 1 + assert harness.saved_hosts[0].instance_id == "PluginA" + assert harness.set_calls == [("PluginA", "PluginA")] + + +def test_set_target_does_not_resave_already_persisted_host(): + """本体已经落盘过时不重复保存,只转发置位调用。""" + harness = _Harness(host_instance=_host("PluginA")) + + harness.build().set_target("PluginA", "PluginA") + + assert harness.saved_hosts == [] + assert harness.set_calls == [("PluginA", "PluginA")] + + +def test_set_target_delegates_clone_to_atomic_callable(): + """目标是已归属该插件的分身时,直接转发给原子置位端口。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginAx2", "PluginA")], + ) + + result = harness.build().set_target("PluginA", "PluginAx2") + + assert result is True + assert harness.set_calls == [("PluginA", "PluginAx2")] + + +def test_set_target_rejects_instance_not_belonging_to_plugin(): + """目标实例不存在或归属另一个插件时拒绝,且不下发到原子置位端口。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginBx2", "PluginB")], + ) + + result = harness.build().set_target("PluginA", "PluginBx2") + + assert result is False + assert harness.set_calls == [] + + +def test_set_target_propagates_atomic_callable_failure(): + """原子置位端口报告目标不存在时如实透传,不伪装成功。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginAx2", "PluginA")], + set_result=False, + ) + + assert harness.build().set_target("PluginA", "PluginAx2") is False + + +def test_set_target_raises_when_plugin_missing(): + """插件本身不存在时拒绝设置默认目标。""" + harness = _Harness(plugin_exists=False) + + with pytest.raises(LookupError): + harness.build().set_target("Missing", "Missing") + + +# --------------------------------------------------------------------------- # +# clear_target() +# --------------------------------------------------------------------------- # + + +def test_clear_target_clears_when_matching_current_default(): + """请求清除的实例正是当前默认目标时才真正清除。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginAx2", "PluginA", default=True)], + ) + + harness.build().clear_target("PluginA", "PluginAx2") + + assert harness.clear_calls == ["PluginA"] + + +def test_clear_target_is_noop_when_nothing_is_set(): + """插件当前没有任何默认目标置位时按空操作处理。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[_clone("PluginAx2", "PluginA")], + ) + + harness.build().clear_target("PluginA", "PluginAx2") + + assert harness.clear_calls == [] + + +def test_clear_target_does_not_touch_a_different_current_default(): + """请求清除的实例并非当前默认目标时,不得误清另一个实例的置位。""" + harness = _Harness( + host_instance=_host("PluginA"), + clones=[ + _clone("PluginAx2", "PluginA", default=True), + _clone("PluginAx3", "PluginA"), + ], + ) + + harness.build().clear_target("PluginA", "PluginAx3") + + assert harness.clear_calls == [] + + +def test_clear_target_raises_when_plugin_missing(): + """插件本身不存在时拒绝清除默认目标。""" + harness = _Harness(plugin_exists=False) + + with pytest.raises(LookupError): + harness.build().clear_target("Missing", "Missing") diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index f5c564810e..885e870690 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -1025,6 +1025,43 @@ async def read_body() -> bytes: plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork") +def test_virtual_instance_static_file_reads_the_instances_bound_version_directory( + tmp_path, monkeypatch +): + """源插件安装了多个版本时,分身按自身版本绑定读取源插件对应版本目录。""" + old_file = tmp_path / "app/plugins/demoplugin/v1_0_0/dist/remoteEntry.js" + old_file.parent.mkdir(parents=True) + old_file.write_text("export default 'v1'", encoding="utf-8") + new_file = tmp_path / "app/plugins/demoplugin/v2_0_0/dist/remoteEntry.js" + new_file.parent.mkdir(parents=True) + new_file.write_text("export default 'v2'", encoding="utf-8") + plugin_manager = MagicMock() + plugin_manager.get_plugin_source_id.return_value = "DemoPlugin" + plugin_manager.get_plugin_instance.return_value = PluginInstance( + instance_id="DemoPluginwork", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=False, + ) + monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) + monkeypatch.setattr( + plugin_endpoint, + "get_api_runtime_config_snapshot", + lambda: MagicMock(root_path=tmp_path), + ) + + response = asyncio.run( + plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None) + ) + + async def read_body() -> bytes: + """读取流式响应的全部测试内容。""" + return b"".join([chunk async for chunk in response.body_iterator]) + + assert asyncio.run(read_body()) == b"export default 'v1'" + plugin_manager.get_plugin_instance.assert_called_once_with("DemoPluginwork") + + def test_uninstall_virtual_instance_never_removes_source_package(monkeypatch): """卸载虚拟实例只清理实例状态,不触碰源插件安装清单或目录。""" plugin_manager = MagicMock() diff --git a/tests/test_plugin_external_install_boundary.py b/tests/test_plugin_external_install_boundary.py index 17bb9b7142..6f080c4629 100644 --- a/tests/test_plugin_external_install_boundary.py +++ b/tests/test_plugin_external_install_boundary.py @@ -143,6 +143,7 @@ async def install(path, _find_links=None): result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", + plugin_dir, lambda: calls.append(("observe", None)), ) @@ -175,6 +176,7 @@ def failing_observer(): result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", + plugin_dir, failing_observer, ) @@ -189,11 +191,13 @@ async def test_dependency_observer_is_not_called_without_manifest( ) -> None: """未声明依赖的插件不承担原生环境快照成本。""" plugin_root = tmp_path / "plugins" - (plugin_root / "demoplugin").mkdir(parents=True) + plugin_dir = plugin_root / "demoplugin" + plugin_dir.mkdir(parents=True) manager = PluginPackageManager(plugin_root=plugin_root) observer = Mock() result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", + plugin_dir, observer, ) @@ -235,6 +239,7 @@ async def test_empty_dependency_manifest_does_not_trigger_native_snapshot( result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", + plugin_dir, observer, ) @@ -272,6 +277,7 @@ async def install(path, _find_links=None): result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", + plugin_dir, lambda: calls.append(("observe", None)), ) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index bd465ea713..33eefe380e 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -21,7 +21,10 @@ PluginPackageSourceClient, ) from app.adapters.system.plugin.health import PluginRuntimeHealth -from app.adapters.system.plugin.package import PluginPackageManager +from app.adapters.system.plugin.package import ( + PluginPackageManager, + _PluginContentPlacement, +) PLUGIN_ID = "DemoPlugin" REPO_URL = "https://github.com/demo/MoviePilot-Plugins" @@ -195,6 +198,14 @@ def guarded_mkdir(path: Path, *args, **kwargs): monkeypatch.setattr(Path, "mkdir", guarded_mkdir) +def _stage_trivial_plugin_content(staging_dir: Path) -> None: + """在给定暂存目录写入一份最小可换入的插件源码,供换入步骤消费。""" + staging_dir.mkdir(parents=True, exist_ok=True) + (staging_dir / "__init__.py").write_text( + "class DemoPlugin:\n pass\n", encoding="utf-8" + ) + + def _patch_sync_remote_install(helper, monkeypatch, meta: dict, release_result: tuple[bool, str], filelist_result: tuple[bool, str] = (True, "")): @@ -204,14 +215,29 @@ def _patch_sync_remote_install(helper, monkeypatch, meta: dict, monkeypatch.setattr(helper, "_PluginPackageManager__get_plugin_meta", lambda *_args: meta) monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: None) monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: calls.append("remove")) - monkeypatch.setattr(helper, "_PluginPackageManager__install_dependencies_if_required", lambda _pid: (False, True, "")) + monkeypatch.setattr( + helper, + "_PluginPackageManager__place_staged_plugin_content", + lambda _pid, plugin_dir, _staging_dir, _source_label: _PluginContentPlacement( + plugin_dir, "", None, True, None + ), + ) + monkeypatch.setattr( + helper, + "_PluginPackageManager__install_dependencies_if_required", + lambda _pid, _content_dir=None, _before=None: (False, True, ""), + ) - def fake_release(_pid, _user_repo, _release_tag): + def fake_release(_pid, _user_repo, _release_tag, staging_dir): calls.append("release") + if release_result[0]: + _stage_trivial_plugin_content(staging_dir) return release_result - def fake_filelist(_pid, _user_repo, _package_version): + def fake_filelist(_pid, _user_repo, _package_version, staging_dir): calls.append("filelist") + if filelist_result[0]: + _stage_trivial_plugin_content(staging_dir) return filelist_result monkeypatch.setattr(helper, "_PluginPackageManager__install_from_release", fake_release) @@ -237,25 +263,36 @@ async def fake_backup(_pid): async def fake_remove(_pid): calls.append("remove") - async def fake_dependencies(_pid): + async def fake_dependencies(_pid, _content_dir=None, _before=None): return False, True, "" - async def fake_release(_pid, _user_repo, _release_tag): + async def fake_release(_pid, _user_repo, _release_tag, staging_dir): calls.append("release") + if release_result[0]: + _stage_trivial_plugin_content(staging_dir) return release_result - async def fake_filelist(_pid, _user_repo, _package_version): + async def fake_filelist(_pid, _user_repo, _package_version, staging_dir): calls.append("filelist") + if filelist_result[0]: + _stage_trivial_plugin_content(staging_dir) return filelist_result async def fake_to_thread(func, *args, **kwargs): - calls.append(("to_thread", func, args, kwargs)) - return None + """把并存检查与内容换入这两个真实经线程池调用的步骤原样同步执行。""" + return func(*args, **kwargs) monkeypatch.setattr(helper, "async_get_plugin_package_version", fake_package_version) monkeypatch.setattr(helper, "_PluginPackageManager__async_get_plugin_meta", fake_meta) monkeypatch.setattr(helper, "_PluginPackageManager__async_backup_plugin", fake_backup) monkeypatch.setattr(helper, "_PluginPackageManager__async_remove_old_plugin", fake_remove) + monkeypatch.setattr( + helper, + "_PluginPackageManager__place_staged_plugin_content", + lambda _pid, plugin_dir, _staging_dir, _source_label: _PluginContentPlacement( + plugin_dir, "", None, True, None + ), + ) monkeypatch.setattr(helper, "_PluginPackageManager__async_install_dependencies_if_required", fake_dependencies) monkeypatch.setattr(helper, "_PluginPackageManager__async_install_from_release", fake_release) monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_async", fake_filelist) @@ -2226,7 +2263,7 @@ def test_install_uses_release_package_when_asset_is_available(self, monkeypatch) assert success assert "" == message - assert ["remove", "release"] == calls + assert ["release"] == calls def test_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch): """ @@ -2250,7 +2287,7 @@ def test_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatc assert success assert "" == message - assert ["remove", "release", "remove", "filelist"] == calls + assert ["release", "filelist"] == calls def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch): """ @@ -2274,7 +2311,7 @@ def test_install_reports_filelist_error_after_release_fallback_fails(self, monke assert not success assert "DemoPlugin 插件源码目录不存在" == message - assert ["remove", "release", "remove", "filelist", "remove"] == calls + assert ["release", "filelist"] == calls def test_install_uses_filelist_when_release_flag_is_disabled(self, monkeypatch): """ @@ -2298,7 +2335,7 @@ def test_install_uses_filelist_when_release_flag_is_disabled(self, monkeypatch): assert success assert "" == message - assert ["remove", "filelist"] == calls + assert ["filelist"] == calls def test_install_rejects_release_without_version(self, monkeypatch): """ @@ -2408,7 +2445,7 @@ def test_install_old_release_version_uses_release_asset_without_filelist_fallbac assert not success assert "未找到资产文件:demoplugin_v1.2.0.zip" == message - assert ["remove", "release", "remove"] == calls + assert ["release"] == calls def test_install_rejects_release_version_missing_from_release_list(self, monkeypatch): """ @@ -2496,12 +2533,28 @@ def test_install_uses_default_package_version_when_not_provided(self, monkeypatc helper = _package_owner(PluginHelper()) seen_versions = [] + + def fake_filelist(*args): + _stage_trivial_plugin_content(args[-1]) + return True, "" + monkeypatch.setattr(helper, "get_plugin_package_version", lambda _pid, _repo, version: seen_versions.append(version) or "") monkeypatch.setattr(helper, "_PluginPackageManager__get_plugin_meta", lambda *_args: {"release": False, "version": "1.2.3"}) monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: None) monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: None) - monkeypatch.setattr(helper, "_PluginPackageManager__install_dependencies_if_required", lambda _pid: (False, True, "")) - monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_sync", lambda *_args: (True, "")) + monkeypatch.setattr( + helper, + "_PluginPackageManager__place_staged_plugin_content", + lambda _pid, plugin_dir, _staging_dir, _source_label: _PluginContentPlacement( + plugin_dir, "", None, True, None + ), + ) + monkeypatch.setattr( + helper, + "_PluginPackageManager__install_dependencies_if_required", + lambda _pid, _content_dir=None, _before=None: (False, True, ""), + ) + monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_sync", fake_filelist) success, message = helper.install_raw(PLUGIN_ID, REPO_URL, force_install=True) @@ -2575,7 +2628,7 @@ def test_install_release_download_failure_falls_back_to_filelist(self, monkeypat assert success assert "" == message - assert ["remove", "release", "remove", "filelist"] == calls + assert ["release", "filelist"] == calls def test_async_install_uses_release_package_when_asset_is_available(self, monkeypatch): """ @@ -2600,7 +2653,7 @@ def test_async_install_uses_release_package_when_asset_is_available(self, monkey assert success assert "" == message - assert calls == ["remove", "release"] + assert calls == ["release"] def test_async_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch): """ @@ -2626,7 +2679,7 @@ def test_async_install_falls_back_to_filelist_when_release_is_missing(self, monk assert success assert "" == message - assert calls == ["remove", "release", "remove", "filelist"] + assert calls == ["release", "filelist"] def test_async_install_old_release_version_uses_release_asset_without_filelist_fallback(self, monkeypatch): """ @@ -2660,7 +2713,7 @@ async def fake_releases(*_args): assert not success assert "未找到资产文件:demoplugin_v1.2.0.zip" == message - assert calls[:3] == ["remove", "release", "remove"] + assert calls == ["release"] def test_async_install_rejects_release_version_missing_from_release_list(self, monkeypatch): """ @@ -2718,7 +2771,7 @@ def test_async_install_reports_filelist_error_after_release_fallback_fails(self, assert not success assert "DemoPlugin 插件源码目录不存在" == message - assert calls == ["remove", "release", "remove", "filelist", "remove"] + assert calls == ["release", "filelist"] def test_async_install_release_fallback_preserves_plugin_id(self, monkeypatch): """ @@ -2739,8 +2792,9 @@ def test_async_install_release_fallback_preserves_plugin_id(self, monkeypatch): (True, ""), ) - async def fake_filelist(pid, _user_repo, _package_version): + async def fake_filelist(pid, _user_repo, _package_version, staging_dir): filelist_pids.append(pid) + _stage_trivial_plugin_content(staging_dir) return True, "" monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_async", fake_filelist) @@ -2772,8 +2826,9 @@ def test_async_install_non_release_preserves_plugin_id(self, monkeypatch): (True, ""), ) - async def fake_filelist(pid, _user_repo, _package_version): + async def fake_filelist(pid, _user_repo, _package_version, staging_dir): filelist_pids.append(pid) + _stage_trivial_plugin_content(staging_dir) return True, "" monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_async", fake_filelist) @@ -2798,7 +2853,9 @@ def test_install_from_release_reports_missing_tag(self, monkeypatch): helper = _package_owner(PluginHelper()) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(404)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "DemoPlugin_v1.2.3 插件发布包不存在" == message @@ -2842,7 +2899,9 @@ def test_install_from_release_reports_missing_asset(self, monkeypatch): lambda *_args, **_kwargs: _FakeResponse(200, {"assets": [{"name": "other.zip", "id": 1}]}), ) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "未找到资产文件:demoplugin_v1.2.3.zip" == message @@ -2863,7 +2922,9 @@ def test_install_from_release_reports_missing_asset_id(self, monkeypatch): lambda *_args, **_kwargs: _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip"}]}), ) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "资产缺少ID信息" == message @@ -2887,7 +2948,9 @@ def json(self): helper = _package_owner(PluginHelper()) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: BadResponse(200)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "解析 Release 信息失败" in message @@ -2908,7 +2971,9 @@ def test_install_from_release_reports_asset_download_failure(self, monkeypatch): ]) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "下载资产失败:502" == message @@ -2941,7 +3006,9 @@ def test_install_from_release_rejects_unsafe_zip_member(self, monkeypatch, tmp_p _patch_release_install_settings(monkeypatch, tmp_path) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "非法 Release 压缩包成员" in message @@ -2977,7 +3044,9 @@ def test_install_from_release_extracts_zip_with_top_level_directory(self, monkey )) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert success assert "" == message @@ -3010,7 +3079,9 @@ def test_install_from_release_creates_directory_entries(self, monkeypatch, tmp_p )) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert success assert "" == message @@ -3033,7 +3104,9 @@ def test_install_from_release_reports_empty_zip(self, monkeypatch): ]) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "压缩包内容为空" == message @@ -3061,7 +3134,9 @@ def test_install_from_release_reports_directory_only_zip(self, monkeypatch, tmp_ )) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "压缩包中无可写入文件" == message @@ -3082,14 +3157,16 @@ def test_install_from_release_reports_bad_zip(self, monkeypatch): ]) monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) assert not success assert "解压 Release 压缩包失败" in message - def test_install_flow_sync_restores_backup_when_prepare_fails(self, monkeypatch): + def test_install_flow_sync_leaves_plugin_dir_untouched_when_prepare_fails(self, monkeypatch): """ - 内容准备失败时恢复备份,避免安装失败后留下半成品目录。 + 内容准备阶段就失败时插件根目录从未被触碰,不备份也不清理,无需回滚。 """ try: from app.adapters.external.market import PluginHelper @@ -3098,19 +3175,19 @@ def test_install_flow_sync_restores_backup_when_prepare_fails(self, monkeypatch) helper = _package_owner(PluginHelper()) calls = [] - monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: "/backup") + monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: calls.append("backup") or "/backup") monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: calls.append("remove")) monkeypatch.setattr(helper, "_PluginPackageManager__restore_plugin", lambda _pid, _backup: calls.append("restore")) success, message = helper._PluginPackageManager__install_flow_sync( - PLUGIN_ID, False, lambda: (False, "prepare failed") + PLUGIN_ID, False, lambda _staging_dir: (False, "prepare failed") ) assert not success assert "prepare failed" == message - assert ["remove", "restore"] == calls + assert [] == calls - def test_install_flow_sync_restores_backup_when_dependency_install_fails(self, monkeypatch): + def test_install_flow_sync_restores_backup_when_dependency_install_fails(self, tmp_path, monkeypatch): """ 依赖安装失败时恢复备份,避免新插件内容破坏可用版本。 """ @@ -3119,7 +3196,7 @@ def test_install_flow_sync_restores_backup_when_dependency_install_fails(self, m except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = _package_owner(PluginHelper()) + helper = _package_owner(PluginHelper(), plugin_root=tmp_path / "plugins") calls = [] monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: "/backup") monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: calls.append("remove")) @@ -3127,16 +3204,20 @@ def test_install_flow_sync_restores_backup_when_dependency_install_fails(self, m monkeypatch.setattr( helper, "_PluginPackageManager__install_dependencies_if_required", - lambda _pid: (True, False, "dependency failed"), + lambda _pid, _content_dir=None, _before=None: (True, False, "dependency failed"), ) + def prepare_content(staging_dir): + _stage_trivial_plugin_content(staging_dir) + return True, "" + success, message = helper._PluginPackageManager__install_flow_sync( - PLUGIN_ID, False, lambda: (True, "") + PLUGIN_ID, False, prepare_content ) assert not success assert "dependency failed" == message - assert ["remove", "restore"] == calls + assert ["restore"] == calls def test_install_flow_sync_restores_backup_for_invalid_modern_manifest(self, tmp_path, monkeypatch): """现代清单无效时恢复旧插件目录。""" @@ -3149,9 +3230,9 @@ def test_install_flow_sync_restores_backup_for_invalid_modern_manifest(self, tmp monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root) monkeypatch.setattr(market_module.settings, "CONFIG_DIR", str(tmp_path)) - def prepare_content(): - plugin_dir.mkdir(parents=True) - (plugin_dir / "pyproject.toml").write_text( + def prepare_content(staging_dir): + staging_dir.mkdir(parents=True, exist_ok=True) + (staging_dir / "pyproject.toml").write_text( "[project]\nname = 'demo'\n", encoding="utf-8", ) @@ -3194,7 +3275,7 @@ def test_install_dependencies_prefers_plugin_pyproject(self, tmp_path, monkeypat lambda path: seen.append(path) or (True, ""), ) - result = helper._PluginPackageManager__install_dependencies_if_required("DemoPlugin") + result = helper._PluginPackageManager__install_dependencies_if_required("DemoPlugin", plugin_dir) assert result == (True, True, "") assert seen == [pyproject_file] @@ -3229,7 +3310,7 @@ async def fake_install(path, _find_links=None): ) result = asyncio.run( - helper._PluginPackageManager__async_install_dependencies_if_required("DemoPlugin") + helper._PluginPackageManager__async_install_dependencies_if_required("DemoPlugin", plugin_dir) ) assert result == (True, True, "") @@ -3261,11 +3342,15 @@ def fake_download(*args): fake_download, ) - success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") + dest_root = helper._plugins_root() / "demoplugin" + + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync( + "demoplugin", "demo/repo", "v2", dest_root + ) assert success assert "" == message - assert calls == [("demoplugin", file_list, "demo/repo", "v2")] + assert calls == [("demoplugin", file_list, "demo/repo", "v2", dest_root)] def test_prepare_content_via_filelist_sync_reports_missing_file_list(self, monkeypatch): """ @@ -3279,7 +3364,9 @@ def test_prepare_content_via_filelist_sync_reports_missing_file_list(self, monke helper = _package_owner(PluginHelper()) monkeypatch.setattr(helper, "_PluginPackageManager__get_file_list", lambda *_args: ([], "list failed")) - success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync( + "demoplugin", "demo/repo", "v2", helper._plugins_root() / "demoplugin" + ) assert not success assert "list failed" == message @@ -3308,6 +3395,7 @@ def fake_file_list(pid, *_args): PLUGIN_ID, "demo/repo", "v2", + helper._plugins_root() / "demoplugin", ) assert not success @@ -3327,7 +3415,9 @@ def test_prepare_content_via_filelist_sync_returns_download_error(self, monkeypa monkeypatch.setattr(helper, "_PluginPackageManager__get_file_list", lambda *_args: ([{"name": "__init__.py"}], "")) monkeypatch.setattr(helper, "_PluginPackageManager__download_files", lambda *_args: (False, "download failed")) - success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync( + "demoplugin", "demo/repo", "v2", helper._plugins_root() / "demoplugin" + ) assert not success assert "download failed" == message @@ -3356,14 +3446,17 @@ async def fake_download(*args): monkeypatch.setattr(helper, "_PluginPackageManager__async_get_file_list", fake_file_list) monkeypatch.setattr(helper, "_PluginPackageManager__async_download_files", fake_download) + dest_root = helper._plugins_root() / "demoplugin" success, message = asyncio.run( - helper._PluginPackageManager__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") + helper._PluginPackageManager__prepare_content_via_filelist_async( + "demoplugin", "demo/repo", "v2", dest_root + ) ) assert success assert "" == message - assert calls == [("demoplugin", file_list, "demo/repo", "v2")] + assert calls == [("demoplugin", file_list, "demo/repo", "v2", dest_root)] def test_async_prepare_content_via_filelist_reports_missing_file_list(self, monkeypatch): """ @@ -3382,7 +3475,9 @@ async def fake_file_list(*_args): monkeypatch.setattr(helper, "_PluginPackageManager__async_get_file_list", fake_file_list) success, message = asyncio.run( - helper._PluginPackageManager__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") + helper._PluginPackageManager__prepare_content_via_filelist_async( + "demoplugin", "demo/repo", "v2", helper._plugins_root() / "demoplugin" + ) ) assert not success @@ -3413,6 +3508,7 @@ async def fake_file_list(pid, *_args): PLUGIN_ID, "demo/repo", "v2", + helper._plugins_root() / "demoplugin", ) ) @@ -3441,15 +3537,17 @@ async def fake_download(*_args): monkeypatch.setattr(helper, "_PluginPackageManager__async_download_files", fake_download) success, message = asyncio.run( - helper._PluginPackageManager__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") + helper._PluginPackageManager__prepare_content_via_filelist_async( + "demoplugin", "demo/repo", "v2", helper._plugins_root() / "demoplugin" + ) ) assert not success assert "download failed" == message - def test_install_flow_async_restores_backup_when_prepare_fails(self, monkeypatch): + def test_install_flow_async_leaves_plugin_dir_untouched_when_prepare_fails(self, monkeypatch): """ - 异步内容准备失败时恢复备份。 + 异步内容准备阶段就失败时插件根目录从未被触碰,不备份也不清理,无需回滚。 """ try: from app.adapters.external.market import PluginHelper @@ -3460,6 +3558,7 @@ def test_install_flow_async_restores_backup_when_prepare_fails(self, monkeypatch calls = [] async def backup(_pid): + calls.append("backup") return "/backup" async def remove(_pid): @@ -3468,7 +3567,7 @@ async def remove(_pid): async def restore(_pid, _backup): calls.append("restore") - async def prepare(): + async def prepare(_staging_dir): return False, "prepare failed" monkeypatch.setattr(helper, "_PluginPackageManager__async_backup_plugin", backup) @@ -3479,9 +3578,9 @@ async def prepare(): assert not success assert "prepare failed" == message - assert ["remove", "restore"] == calls + assert [] == calls - def test_install_flow_async_restores_backup_when_dependency_install_fails(self, monkeypatch): + def test_install_flow_async_restores_backup_when_dependency_install_fails(self, tmp_path, monkeypatch): """ 异步依赖安装失败时恢复备份。 """ @@ -3490,7 +3589,7 @@ def test_install_flow_async_restores_backup_when_dependency_install_fails(self, except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = _package_owner(PluginHelper()) + helper = _package_owner(PluginHelper(), plugin_root=tmp_path / "plugins") calls = [] async def backup(_pid): @@ -3502,10 +3601,11 @@ async def remove(_pid): async def restore(_pid, _backup): calls.append("restore") - async def prepare(): + async def prepare(staging_dir): + _stage_trivial_plugin_content(staging_dir) return True, "" - async def dependencies(_pid): + async def dependencies(_pid, _content_dir=None, _before=None): return True, False, "dependency failed" monkeypatch.setattr(helper, "_PluginPackageManager__async_backup_plugin", backup) @@ -3517,7 +3617,7 @@ async def dependencies(_pid): assert not success assert "dependency failed" == message - assert ["remove", "restore"] == calls + assert ["restore"] == calls def test_async_install_from_release_reports_missing_asset(self, monkeypatch): """ @@ -3536,7 +3636,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success @@ -3559,7 +3661,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success @@ -3611,7 +3715,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success @@ -3638,7 +3744,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success @@ -3677,7 +3785,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success @@ -3714,7 +3824,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert success @@ -3742,7 +3854,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success @@ -3769,7 +3883,9 @@ async def fake_request(*_args, **_kwargs): monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release( + PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3", helper._plugins_root() / PLUGIN_ID.lower() + ) ) assert not success diff --git a/tests/test_plugin_install_backup_cleanup.py b/tests/test_plugin_install_backup_cleanup.py index 5ea1529321..526353fee4 100644 --- a/tests/test_plugin_install_backup_cleanup.py +++ b/tests/test_plugin_install_backup_cleanup.py @@ -6,7 +6,10 @@ from app.adapters.external.market import PluginHelper from app.adapters.external.plugin.client import PluginPackageSourceClient -from app.adapters.system.plugin.package import PluginPackageManager +from app.adapters.system.plugin.package import ( + PluginPackageManager, + _PluginContentPlacement, +) @pytest.mark.asyncio @@ -33,10 +36,17 @@ async def test_successful_install_flows_remove_transient_backups( "_PluginPackageManager__remove_old_plugin", lambda _pid: None, ) + monkeypatch.setattr( + package, + "_PluginPackageManager__place_staged_plugin_content", + lambda _pid, _plugin_dir, _staging_dir, _source_label: _PluginContentPlacement( + tmp_path / "content", "", None, True, None + ), + ) monkeypatch.setattr( package, "_PluginPackageManager__install_dependencies_if_required", - lambda _pid: (False, False, "不存在依赖"), + lambda _pid, _content_dir, _before=None: (False, False, "不存在依赖"), ) monkeypatch.setattr( package, @@ -47,7 +57,7 @@ async def test_successful_install_flows_remove_transient_backups( sync_result = package._PluginPackageManager__install_flow_sync( "DemoPlugin", False, - lambda: (True, ""), + lambda _staging_dir: (True, ""), ) async def backup_plugin(_pid: str) -> str: @@ -57,11 +67,13 @@ async def backup_plugin(_pid: str) -> str: async def remove_plugin(_pid: str) -> None: """隔离测试中的真实插件目录删除。""" - async def install_dependencies(_pid: str) -> tuple[bool, bool, str]: + async def install_dependencies( + _pid: str, _content_dir: Path, _before=None + ) -> tuple[bool, bool, str]: """表示测试插件没有额外依赖。""" return False, False, "不存在依赖" - async def prepare_content() -> tuple[bool, str]: + async def prepare_content(_staging_dir: Path) -> tuple[bool, str]: """表示异步内容准备成功。""" return True, "" diff --git a/tests/test_plugin_instance_default_target_endpoints.py b/tests/test_plugin_instance_default_target_endpoints.py new file mode 100644 index 0000000000..fc7700c53e --- /dev/null +++ b/tests/test_plugin_instance_default_target_endpoints.py @@ -0,0 +1,119 @@ +"""插件实例默认调用目标设置与清除接口测试。""" + +from __future__ import annotations + +import inspect + +import pytest +from fastapi import HTTPException + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.endpoints import pluginversion as pluginversion_endpoint +from app.api.endpoints.pluginversion import ( + clear_plugin_instance_default_target, + set_plugin_instance_default_target, +) + + +def _depends_default(func, parameter_name: str): + """取出端点函数指定参数的 FastAPI Depends 默认值。""" + return inspect.signature(func).parameters[parameter_name].default + + +def _manager(**methods): + """按方法名快速拼装一个鸭子类型的 Manager 替身。""" + return type("Manager", (), methods)() + + +def test_both_endpoints_require_superuser_dependency(): + """设为默认与清除默认两个端点都要求超级管理员。""" + for func in (set_plugin_instance_default_target, clear_plugin_instance_default_target): + depends = _depends_default(func, "_") + assert depends.dependency is get_current_active_superuser + + +# --------------------------------------------------------------------------- # +# PUT /instances/{plugin_id}/{instance_id}/default_target +# --------------------------------------------------------------------------- # + + +def test_put_delegates_to_manager_and_reports_success(monkeypatch): + """设置请求原样转交给 Manager,命中时返回成功。""" + calls: list = [] + manager = _manager( + set_plugin_instance_default_target=lambda self, *a: (calls.append(a), True)[1] + ) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = set_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None) + + assert result.success is True + assert calls == [("DemoPlugin", "DemoPluginWork")] + + +def test_put_reports_missing_instance_as_404(monkeypatch): + """目标实例不归属该插件时,Manager 返回 False,接口须映射为 404。""" + manager = _manager(set_plugin_instance_default_target=lambda self, *a: False) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + set_plugin_instance_default_target("DemoPlugin", "Missing", None) + + assert excinfo.value.status_code == 404 + + +def test_put_reports_missing_plugin_as_404(monkeypatch): + """插件本身不存在时返回 404。""" + + def _raise(*_a): + raise LookupError("插件 Missing 不存在") + + manager = _manager(set_plugin_instance_default_target=_raise) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + set_plugin_instance_default_target("Missing", "Missing", None) + + assert excinfo.value.status_code == 404 + + +# --------------------------------------------------------------------------- # +# DELETE /instances/{plugin_id}/{instance_id}/default_target +# --------------------------------------------------------------------------- # + + +def test_delete_delegates_to_manager_and_is_idempotent(monkeypatch): + """清除请求原样转交给 Manager,重复调用同样返回成功。""" + calls: list = [] + manager = _manager( + clear_plugin_instance_default_target=lambda self, *a: calls.append(a) + ) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + first = clear_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None) + second = clear_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None) + + assert first.success is True + assert second.success is True + assert calls == [("DemoPlugin", "DemoPluginWork"), ("DemoPlugin", "DemoPluginWork")] + + +def test_delete_reports_missing_plugin_as_404(monkeypatch): + """插件本身不存在时返回 404。""" + + def _raise(*_a): + raise LookupError("插件 Missing 不存在") + + manager = _manager(clear_plugin_instance_default_target=_raise) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + clear_plugin_instance_default_target("Missing", "Missing", None) + + assert excinfo.value.status_code == 404 + + +def test_router_registers_default_target_paths(): + """路由器暴露设为默认与清除默认两个路径,注册在插件前缀下。""" + paths = {route.path for route in pluginversion_endpoint.router.routes} + assert "/instances/{plugin_id}/{instance_id}/default_target" in paths diff --git a/tests/test_plugin_instance_default_target_migration.py b/tests/test_plugin_instance_default_target_migration.py new file mode 100644 index 0000000000..b1887d2ff8 --- /dev/null +++ b/tests/test_plugin_instance_default_target_migration.py @@ -0,0 +1,185 @@ +"""插件实例默认调用目标标记列与条件唯一索引 Alembic 迁移测试。""" + +from __future__ import annotations + +import importlib +from datetime import datetime, timezone + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.dialects import postgresql, sqlite +from sqlalchemy.exc import IntegrityError + +from app.db.models.plugininstance import PluginInstance + +MIGRATION_MODULE = "database.versions.e0e68cbd5756_3_0_31" + + +def _bind_migration(monkeypatch, connection): + """把迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(MIGRATION_MODULE) + context = MigrationContext.configure(connection) + monkeypatch.setattr(migration, "op", Operations(context)) + return migration + + +def _create_legacy_table(connection: sa.engine.Connection) -> None: + """建出加列前的表结构,模拟迁移前的存量数据库。""" + now = datetime.now(timezone.utc).isoformat() + table = sa.Table( + "plugininstance", + sa.MetaData(), + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("instance_id", sa.String(length=128), nullable=False), + sa.Column("source_plugin_id", sa.String(length=128), nullable=False), + sa.Column("plugin_name", sa.String(length=255)), + sa.Column("plugin_desc", sa.String(length=255)), + sa.Column("plugin_icon", sa.String(length=255)), + sa.Column("mode", sa.String(length=16), nullable=False, server_default="virtual"), + sa.Column("plugin_version", sa.String(length=64)), + sa.Column("follow_current_version", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("log_level", sa.String(length=16)), + sa.Column("log_expires_at", sa.String(length=40)), + sa.Column("created_at", sa.String(length=40), nullable=False), + sa.Column("updated_at", sa.String(length=40), nullable=False), + ) + table.create(connection) + connection.execute( + table.insert().values( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + mode="virtual", + follow_current_version=True, + created_at=now, + updated_at=now, + ) + ) + + +def test_default_target_migration_adds_column_and_keeps_existing_rows(monkeypatch) -> None: + """新增列必须可空默认为假,且不得影响已有行;重复升级与完整回滚都要幂等。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_legacy_table(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + columns = { + column["name"]: column + for column in sa.inspect(connection).get_columns("plugininstance") + } + # 与该迁移自身的落点比对,而不是与持续演进的当前模型比对:后续迁移 + # 还会继续给该表加列,本断言不应该随之跟着变红。 + assert columns.keys() == { + "id", + "instance_id", + "source_plugin_id", + "plugin_name", + "plugin_desc", + "plugin_icon", + "mode", + "plugin_version", + "follow_current_version", + "log_level", + "log_expires_at", + "is_default_target", + "created_at", + "updated_at", + } + assert columns["is_default_target"]["nullable"] is False + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + row = connection.execute(sa.select(table)).mappings().one() + assert row["instance_id"] == "DemoPluginWork" + assert bool(row["is_default_target"]) is False + + migration.downgrade() + remaining = { + column["name"] for column in sa.inspect(connection).get_columns("plugininstance") + } + assert "is_default_target" not in remaining + remaining_indexes = { + index["name"] for index in sa.inspect(connection).get_indexes("plugininstance") + } + assert "ux_plugininstance_default_target" not in remaining_indexes + + migration.upgrade() + restored = { + column["name"] for column in sa.inspect(connection).get_columns("plugininstance") + } + assert "is_default_target" in restored + + +def test_default_target_migration_accepts_fresh_current_schema(monkeypatch) -> None: + """create_all 已建当前表时重复升级不得因列或索引已存在而报错。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + PluginInstance.__table__.create(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + assert { + column["name"] + for column in sa.inspect(connection).get_columns("plugininstance") + } == {column.name for column in PluginInstance.__table__.columns} + assert "ux_plugininstance_default_target" in { + index["name"] for index in sa.inspect(connection).get_indexes("plugininstance") + } + + +def test_default_target_migration_index_rejects_a_second_default_target(monkeypatch) -> None: + """迁移建出的条件唯一索引必须在真实数据库连接上拒绝第二条置位。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_legacy_table(connection) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + now = datetime.now(timezone.utc).isoformat() + connection.execute( + table.insert().values( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + mode="host", + follow_current_version=True, + is_default_target=True, + created_at=now, + updated_at=now, + ) + ) + + with pytest.raises(IntegrityError): + connection.execute( + table.update() + .where(table.c.instance_id == "DemoPluginWork") + .values(is_default_target=True) + ) + + +def test_default_target_index_is_partial_in_both_dialects() -> None: + """模型(``create_all`` 路径)建出的索引在两种方言下都必须带谓词。 + + 本仓测试库是 SQLite,PostgreSQL 分支只能靠编译期 DDL 证明:谓词整个丢失会 + 退化成「每个源插件只能有一行实例」,把插件分身整个锁死。 + """ + index = next( + item for item in PluginInstance.__table__.indexes + if item.name == "ux_plugininstance_default_target" + ) + ddl = sa.schema.CreateIndex(index) + + assert str(ddl.compile(dialect=sqlite.dialect())).strip() == ( + "CREATE UNIQUE INDEX ux_plugininstance_default_target " + "ON plugininstance (source_plugin_id) WHERE is_default_target IS 1" + ) + assert str(ddl.compile(dialect=postgresql.dialect())).strip() == ( + "CREATE UNIQUE INDEX ux_plugininstance_default_target " + "ON plugininstance (source_plugin_id) WHERE is_default_target IS true" + ) diff --git a/tests/test_plugin_instance_log_context_entrypoints.py b/tests/test_plugin_instance_log_context_entrypoints.py new file mode 100644 index 0000000000..4d187a6fe0 --- /dev/null +++ b/tests/test_plugin_instance_log_context_entrypoints.py @@ -0,0 +1,347 @@ +"""插件实例日志上下文接入点契约测试。 + +覆盖四类宿主受控调用点:插件实例的构造与 `init_plugin` +(`PluginLifecycle.start`/`initialize`)、事件处理器回调(`EventDispatcher` +的四个 invoke 方法)、定时服务回调(`SchedulerReconcileOwner.update_plugin_job`)、 +HTTP API 端点回调(`PluginProjection.apis`)。每个用例只断言绑定期间 +`current_plugin_instance_id()` 能读到发起调用的实例 ID,且调用结束后上下文 +必须恢复为未绑定,不污染后续用例。 +""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import app.scheduler.reconcile as reconcile_module +from app.runtime.event.binding import EventBindingResolver, EventHandlerBinding +from app.runtime.event.dispatch import EventDispatcher +from app.runtime.extensions.plugin.database import PluginDatabase +from app.runtime.extensions.plugin.lifecycle import PluginLifecycle +from app.runtime.extensions.plugin.projection import PluginProjection +from app.runtime.log import current_plugin_instance_id +from app.scheduler.reconcile import SchedulerReconcileOwner + +# --------------------------------------------------------------------------- +# 1. 插件实例构造与 init_plugin +# --------------------------------------------------------------------------- + + +def _lifecycle(*, plugins): + """构造隔离外部事件和模块清理的生命周期实例。""" + classes: dict = {} + running: dict = {} + lifecycle = PluginLifecycle( + classes=classes, + running=running, + load_plugins=lambda _pid, _installed, _check, _version=None: list(plugins), + installed_plugins=lambda: ["DemoPluginWork"], + plugin_config=lambda _pid: {}, + auth_checker=lambda _plugin: True, + clear_modules=MagicMock(), + clear_tools=MagicMock(), + enable_events=MagicMock(), + disable_events=MagicMock(), + runtime_status_writer=MagicMock(), + database=lambda: PluginDatabase(), + log=MagicMock(), + event_sender=MagicMock(), + ) + return lifecycle, classes, running + + +def test_lifecycle_start_binds_instance_during_construct_and_init_plugin(): + """构造与 init_plugin 期间应能读到发起它的实例 ID,start 返回后上下文必须清空。""" + seen: list[str | None] = [] + + class _Plugin: + plugin_name = "演示插件" + plugin_version = "1.0.0" + + def __init__(self) -> None: + seen.append(("construct", current_plugin_instance_id())) + + def init_plugin(self, _config: dict) -> None: + seen.append(("init", current_plugin_instance_id())) + + @staticmethod + def get_state() -> bool: + return True + + _Plugin.__name__ = "DemoPluginWork" + lifecycle, _classes, _running = _lifecycle(plugins=[_Plugin]) + + lifecycle.start("DemoPluginWork") + + assert seen == [ + ("construct", "DemoPluginWork"), + ("init", "DemoPluginWork"), + ] + assert current_plugin_instance_id() is None + + +def test_lifecycle_initialize_binds_instance_during_reinit(): + """公开 init_plugin(配置页重新生效)同样要绑定发起它的实例。""" + seen: list[str | None] = [] + + class _Plugin: + def init_plugin(self, _config: dict) -> None: + seen.append(current_plugin_instance_id()) + + @staticmethod + def get_state() -> bool: + return True + + running = {"DemoPluginWork": _Plugin()} + lifecycle = PluginLifecycle( + classes={}, + running=running, + load_plugins=lambda *_a, **_kw: [], + installed_plugins=lambda: [], + plugin_config=lambda _pid: {}, + auth_checker=lambda _plugin: True, + clear_modules=MagicMock(), + clear_tools=MagicMock(), + enable_events=MagicMock(), + disable_events=MagicMock(), + runtime_status_writer=MagicMock(), + database=lambda: PluginDatabase(), + log=MagicMock(), + event_sender=MagicMock(), + ) + + lifecycle.initialize("DemoPluginWork", {"enable": True}) + + assert seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +# --------------------------------------------------------------------------- +# 2. 事件处理器回调 +# --------------------------------------------------------------------------- + + +class _FakeEventType: + """提供 dispatch 内部读取的 `.value` 属性。""" + + value = "test.event" + + +class _FakeEvent: + """携带 dispatch 内部读取的最小事件属性集。""" + + correlation_id = None + event_type = _FakeEventType() + + +def _dispatcher(resolvers: dict) -> EventDispatcher: + """构造只依赖真实 EventBindingResolver 的最小事件调度器。""" + binding_resolver = EventBindingResolver(lock=threading.Lock(), resolvers=lambda: resolvers) + return EventDispatcher( + registry=MagicMock(), + binding_resolver=binding_resolver, + event_factory=MagicMock(), + error_handler=MagicMock(side_effect=AssertionError("handler must not error")), + async_handle_sink=MagicMock(), + sync_handle_sink=MagicMock(), + ) + + +class _PluginEventHandler: + """模拟虚拟实例克隆类:`__name__` 与运行实例 ID 相同。""" + + def __init__(self) -> None: + """记录事件处理期间观察到的实例上下文。""" + self.seen: list[str | None] = [] + + def on_event(self, _event: object) -> None: + """记录当前绑定的实例 ID。""" + self.seen.append(current_plugin_instance_id()) + + async def on_event_async(self, _event: object) -> None: + """异步处理器同样记录当前绑定的实例 ID。""" + self.seen.append(current_plugin_instance_id()) + + +def test_invoke_sync_binds_owning_instance(): + """同步事件处理器执行期间应绑定声明它的插件实例。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True) + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + dispatcher.invoke_sync(_PluginEventHandler.on_event, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +def test_invoke_sync_strict_binds_owning_instance(): + """strict 变体同样要绑定实例,且失败时仍需正确复位上下文。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo") + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + dispatcher.invoke_sync_strict(_PluginEventHandler.on_event, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +@pytest.mark.asyncio +async def test_invoke_async_binds_owning_instance_for_coroutine_handler(): + """异步事件处理器(直接 await)执行期间应绑定声明它的插件实例。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo") + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + await dispatcher.invoke_async(_PluginEventHandler.on_event_async, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +@pytest.mark.asyncio +async def test_invoke_async_binds_owning_instance_across_threadpool_hop(): + """同步处理器经线程池执行时,绑定必须跨越 `run_in_threadpool` 的执行上下文切换。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True) + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + await dispatcher.invoke_async(_PluginEventHandler.on_event, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +def test_invoke_sync_does_not_bind_free_function_handler(): + """自由函数处理器不属于任何插件实例,不应绑定任何上下文。""" + seen: list[str | None] = [] + + def _free_handler(_event: object) -> None: + seen.append(current_plugin_instance_id()) + + dispatcher = _dispatcher({}) + + dispatcher.invoke_sync(_free_handler, _FakeEvent()) + + assert seen == [None] + + +# --------------------------------------------------------------------------- +# 3. 定时服务回调 +# --------------------------------------------------------------------------- + + +def test_update_plugin_job_binds_instance_around_service_callback(monkeypatch): + """插件定时服务被调度器实际调用时应绑定注册它的插件实例。""" + seen: list[str | None] = [] + + def _service_callback() -> None: + seen.append(current_plugin_instance_id()) + + fake_manager = SimpleNamespace( + get_plugin_services=lambda pid: [ + { + "id": "job1", + "name": "演示任务", + "func": _service_callback, + "trigger": "interval", + "kwargs": {"seconds": 60}, + } + ], + get_plugin_attr=lambda _pid, _attr: "演示插件", + ) + monkeypatch.setattr(reconcile_module, "get_plugin_manager", lambda: fake_manager) + + owner = SchedulerReconcileOwner.__new__(SchedulerReconcileOwner) + owner._scheduler = MagicMock() + owner._lock = threading.RLock() + owner._jobs = {} + owner.start = MagicMock() + owner.remove_plugin_job = lambda _pid, job_id=None: None + owner._assign_job_generation = lambda _job_id, _job: None + + owner.update_plugin_job("DemoPluginWork") + + registered_job = owner._jobs["DemoPluginWork_job1"] + registered_job["func"]() + + assert seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +# --------------------------------------------------------------------------- +# 4. HTTP API 端点回调 +# --------------------------------------------------------------------------- + + +class _ApiEndpointPlugin: + """声明一条会读取实例上下文的 HTTP API 路由的最小插件桩。""" + + plugin_name = "接口插件" + + def __init__(self) -> None: + """初始化调用记录。""" + self.seen: list[str | None] = [] + + def get_state(self) -> bool: + """插件始终启用。""" + return True + + def get_name(self) -> str: + """返回插件展示名称。""" + return self.plugin_name + + def get_api(self) -> list[dict]: + """声明一条状态查询路由,endpoint 绑定到本实例的方法。""" + return [{"path": "/status", "endpoint": self.status, "methods": ["GET"]}] + + def status(self) -> dict: + """处理状态查询请求期间记录当前绑定的实例 ID。""" + self.seen.append(current_plugin_instance_id()) + return {"ok": True} + + +def test_projection_api_endpoint_binds_owning_instance_without_any_caller_context(): + """路由被 FastAPI 直接调用(不经过任何宿主受控调用点)时仍应绑定实例。""" + plugin = _ApiEndpointPlugin() + projection = PluginProjection({"DemoPluginWork": plugin}) + + apis = projection.apis() + + assert current_plugin_instance_id() is None + apis[0]["endpoint"]() + + assert plugin.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None diff --git a/tests/test_plugin_instance_log_level_migration.py b/tests/test_plugin_instance_log_level_migration.py new file mode 100644 index 0000000000..eddc695ba3 --- /dev/null +++ b/tests/test_plugin_instance_log_level_migration.py @@ -0,0 +1,127 @@ +"""插件实例日志等级覆盖列 Alembic 迁移测试。""" + +from __future__ import annotations + +import importlib +from datetime import datetime, timezone + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from app.db.models.plugininstance import PluginInstance + +MIGRATION_MODULE = "database.versions.487f7e681955_3_0_30" + + +def _bind_migration(monkeypatch, connection): + """把迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(MIGRATION_MODULE) + context = MigrationContext.configure(connection) + monkeypatch.setattr(migration, "op", Operations(context)) + return migration + + +def _create_legacy_table(connection: sa.engine.Connection) -> None: + """建出加列前的表结构,模拟迁移前的存量数据库。""" + now = datetime.now(timezone.utc).isoformat() + table = sa.Table( + "plugininstance", + sa.MetaData(), + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("instance_id", sa.String(length=128), nullable=False), + sa.Column("source_plugin_id", sa.String(length=128), nullable=False), + sa.Column("plugin_name", sa.String(length=255)), + sa.Column("plugin_desc", sa.String(length=255)), + sa.Column("plugin_icon", sa.String(length=255)), + sa.Column("mode", sa.String(length=16), nullable=False, server_default="virtual"), + sa.Column("plugin_version", sa.String(length=64)), + sa.Column("follow_current_version", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.String(length=40), nullable=False), + sa.Column("updated_at", sa.String(length=40), nullable=False), + ) + table.create(connection) + connection.execute( + table.insert().values( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + mode="virtual", + follow_current_version=True, + created_at=now, + updated_at=now, + ) + ) + + +def test_plugin_instance_log_level_migration_adds_columns_and_keeps_existing_rows( + monkeypatch, +) -> None: + """新增列必须可空,且不得影响已有行;重复升级与完整回滚都要幂等。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_legacy_table(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + columns = { + column["name"]: column + for column in sa.inspect(connection).get_columns("plugininstance") + } + # 与该迁移自身的落点比对,而不是与持续演进的当前模型比对:后续迁移 + # 还会继续给该表加列,本断言不应该随之跟着变红。 + assert columns.keys() == { + "id", + "instance_id", + "source_plugin_id", + "plugin_name", + "plugin_desc", + "plugin_icon", + "mode", + "plugin_version", + "follow_current_version", + "log_level", + "log_expires_at", + "created_at", + "updated_at", + } + assert columns["log_level"]["nullable"] is True + assert columns["log_expires_at"]["nullable"] is True + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + row = connection.execute(sa.select(table)).mappings().one() + assert row["instance_id"] == "DemoPluginWork" + assert row["log_level"] is None + assert row["log_expires_at"] is None + + migration.downgrade() + remaining = { + column["name"] for column in sa.inspect(connection).get_columns("plugininstance") + } + assert "log_level" not in remaining + assert "log_expires_at" not in remaining + + migration.upgrade() + restored = { + column["name"] for column in sa.inspect(connection).get_columns("plugininstance") + } + assert {"log_level", "log_expires_at"}.issubset(restored) + + +def test_plugin_instance_log_level_migration_accepts_fresh_current_schema( + monkeypatch, +) -> None: + """create_all 已建当前表时重复升级不得因列已存在而报错。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + PluginInstance.__table__.create(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + assert { + column["name"] + for column in sa.inspect(connection).get_columns("plugininstance") + } == {column.name for column in PluginInstance.__table__.columns} diff --git a/tests/test_plugin_instance_logging.py b/tests/test_plugin_instance_logging.py new file mode 100644 index 0000000000..a2f3ca4b85 --- /dev/null +++ b/tests/test_plugin_instance_logging.py @@ -0,0 +1,220 @@ +"""插件实例日志等级覆盖缓存与上下文绑定测试。""" + +from __future__ import annotations + +import asyncio +import inspect +from datetime import datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.runtime import log as log_module +from app.runtime.log import ( + LoggerManager, + bind_plugin_instance, + clear_plugin_instance_log_level, + current_plugin_instance_id, + get_effective_plugin_instance_log_level, + get_plugin_instance_log_level_override, + logger, + set_plugin_instance_log_level, + wrap_for_plugin_instance, +) + + +class _CapturingLogWriter: + """记录日志写入目标,避免测试访问真实文件系统。""" + + def __init__(self) -> None: + """初始化空的调用记录列表。""" + self.entries: list[tuple[str, str, Path]] = [] + + def write_log(self, level: str, message: str, file_path: Path) -> None: + """保存单条日志的级别、内容和目标路径。""" + self.entries.append((level, message, file_path)) + + @staticmethod + def shutdown() -> bool: + """测试写入器没有待释放资源。""" + return True + + +@pytest.fixture(autouse=True) +def _isolate_plugin_log_state(monkeypatch): + """快照并还原插件实例日志等级涉及的全部进程内全局状态,避免用例间互相污染。""" + monkeypatch.setattr(log_module, "_plugin_level_overrides", {}) + monkeypatch.setattr(log_module.log_settings, "DEBUG", False) + monkeypatch.setattr(log_module.log_settings, "LOG_LEVEL", "INFO") + yield + + +@pytest.fixture(name="fake_writer") +def fixture_fake_writer(monkeypatch, tmp_path): + """装配内存写入器,跳过真实文件 I/O。""" + writer = _CapturingLogWriter() + monkeypatch.setattr(LoggerManager, "_writer", writer) + monkeypatch.setattr(LoggerManager, "_log_path", tmp_path) + monkeypatch.setattr( + LoggerManager, + "_get_console_logger", + classmethod( + lambda _cls, _logfile: SimpleNamespace( + info=lambda *_a, **_kw: None, + debug=lambda *_a, **_kw: None, + warning=lambda *_a, **_kw: None, + error=lambda *_a, **_kw: None, + critical=lambda *_a, **_kw: None, + ) + ), + ) + return writer + + +def test_set_and_get_effective_level_returns_override(): + """设置覆盖后按实例查询生效等级应返回覆盖值而非全局等级。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + assert get_effective_plugin_instance_log_level("DemoPluginWork") == "DEBUG" + assert get_effective_plugin_instance_log_level("OtherInstance") == "INFO" + + +def test_set_invalid_level_raises_value_error(): + """非受支持的等级名必须拒绝写入缓存。""" + with pytest.raises(ValueError): + set_plugin_instance_log_level("DemoPluginWork", "LOUD") + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + + +def test_clear_resets_to_global_level(): + """清除覆盖后立即回落全局等级。""" + set_plugin_instance_log_level("DemoPluginWork", "ERROR") + + clear_plugin_instance_log_level("DemoPluginWork") + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + assert get_effective_plugin_instance_log_level("DemoPluginWork") == "INFO" + + +def test_clear_is_idempotent_for_unset_instance(): + """清除一个从未设置过覆盖的实例不应报错。""" + clear_plugin_instance_log_level("NeverConfigured") + clear_plugin_instance_log_level("NeverConfigured") + + +def test_expired_override_evicts_on_read(): + """过期覆盖必须在读取时惰性判定并清理,而不是继续生效。""" + set_plugin_instance_log_level( + "DemoPluginWork", "DEBUG", expires_at=datetime.now() - timedelta(seconds=1) + ) + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + assert get_effective_plugin_instance_log_level("DemoPluginWork") == "INFO" + + +def test_unexpired_override_survives_read(): + """未过期覆盖读取后仍然生效,且失效时间原样返回。""" + expires_at = datetime.now() + timedelta(hours=1) + set_plugin_instance_log_level("DemoPluginWork", "WARNING", expires_at=expires_at) + + override = get_plugin_instance_log_level_override("DemoPluginWork") + + assert override is not None + level_name, returned_expiry = override + assert level_name == "WARNING" + assert returned_expiry is not None + assert abs((returned_expiry - expires_at).total_seconds()) < 1 + + +def test_override_does_not_leak_into_unrelated_instance_effective_level(): + """一个实例的覆盖不得影响另一个未设置覆盖实例的生效等级。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + assert get_effective_plugin_instance_log_level("SiblingInstance") == "INFO" + + +def test_current_plugin_instance_id_defaults_to_none(): + """未绑定时读取当前实例上下文应为 None。""" + assert current_plugin_instance_id() is None + + +def test_bind_plugin_instance_sets_and_resets_context(): + """绑定上下文管理器退出后必须恢复为未绑定状态,支持嵌套。""" + assert current_plugin_instance_id() is None + with bind_plugin_instance("Outer"): + assert current_plugin_instance_id() == "Outer" + with bind_plugin_instance("Inner"): + assert current_plugin_instance_id() == "Inner" + assert current_plugin_instance_id() == "Outer" + assert current_plugin_instance_id() is None + + +def test_wrap_for_plugin_instance_binds_sync_callable(): + """同步回调包装后执行期间应能读到绑定的实例 ID。""" + seen: list[str | None] = [] + + def _callback() -> None: + seen.append(current_plugin_instance_id()) + + wrapped = wrap_for_plugin_instance(_callback, "DemoPluginWork") + wrapped() + + assert seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +def test_wrap_for_plugin_instance_binds_async_callable(): + """异步回调包装后仍保持协程函数身份,且执行期间能读到绑定的实例 ID。""" + seen: list[str | None] = [] + + async def _callback() -> None: + seen.append(current_plugin_instance_id()) + + wrapped = wrap_for_plugin_instance(_callback, "DemoPluginWork") + assert inspect.iscoroutinefunction(wrapped) + asyncio.run(wrapped()) + + assert seen == ["DemoPluginWork"] + + +def test_bound_instance_with_lower_override_emits_debug_log(fake_writer): + """绑定实例设了更宽松的覆盖时,全局等级挡不住的 DEBUG 日志应放行。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + with bind_plugin_instance("DemoPluginWork"): + logger.debug("verbose diagnostic") + + assert any("verbose diagnostic" in message for _level, message, _path in fake_writer.entries) + + +def test_unbound_debug_log_is_dropped_by_global_level(fake_writer): + """未绑定任何实例时,DEBUG 日志仍按全局 INFO 等级过滤丢弃。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + logger.debug("verbose diagnostic without binding") + + assert fake_writer.entries == [] + + +def test_bound_instance_with_stricter_override_drops_info_log(fake_writer): + """绑定实例设了更严格的覆盖时,全局等级本会放行的 INFO 日志应被丢弃。""" + set_plugin_instance_log_level("DemoPluginWork", "ERROR") + + with bind_plugin_instance("DemoPluginWork"): + logger.info("routine progress") + + assert fake_writer.entries == [] + + +def test_other_bound_instance_is_unaffected_by_sibling_override(fake_writer): + """一个实例的覆盖不得影响另一个未设置覆盖实例的过滤结果。""" + set_plugin_instance_log_level("DemoPluginWork", "ERROR") + + with bind_plugin_instance("SiblingInstance"): + logger.info("sibling routine progress") + + assert any( + "sibling routine progress" in message for _level, message, _path in fake_writer.entries + ) diff --git a/tests/test_plugin_instance_migration.py b/tests/test_plugin_instance_migration.py new file mode 100644 index 0000000000..b3bef50358 --- /dev/null +++ b/tests/test_plugin_instance_migration.py @@ -0,0 +1,198 @@ +"""插件实例描述符表 Alembic 迁移测试。""" + +from __future__ import annotations + +import importlib + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from app.db.models.plugininstance import PluginInstance +from app.db.models.systemconfig import SystemConfig + +MIGRATION_MODULE = "database.versions.281965691a20_3_0_29" + + +def _bind_migration(monkeypatch, connection): + """把迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(MIGRATION_MODULE) + context = MigrationContext.configure(connection) + monkeypatch.setattr(migration, "op", Operations(context)) + return migration + + +def _seed_legacy_key(connection: sa.engine.Connection, value) -> None: + """写入旧 systemconfig 单键,模拟迁移前的实例描述存量数据。""" + connection.execute( + sa.insert(SystemConfig.__table__).values(key="PluginInstances", value=value) + ) + + +def test_plugin_instance_migration_migrates_legacy_dict_payload_and_keeps_source_key( + monkeypatch, +) -> None: + """字典载荷应逐条搬入新表,原 systemconfig 键保留不删,且可重复升级与完整回滚。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + SystemConfig.__table__.create(connection) + _seed_legacy_key( + connection, + { + "DemoPluginWork": { + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + "plugin_name": "工作实例", + "follow_current_version": False, + "plugin_version": "1.2.0", + }, + }, + ) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + inspector = sa.inspect(connection) + assert "plugininstance" in inspector.get_table_names() + columns = {column["name"] for column in inspector.get_columns("plugininstance")} + # 本迁移只建出它自己声明的列;日志等级覆盖列由后续 487f7e681955 迁移补上, + # 不在这里跟当前完整模型比较,否则每次给该表加列都要回头改这条断言。 + assert columns == { + "id", + "instance_id", + "source_plugin_id", + "plugin_name", + "plugin_desc", + "plugin_icon", + "mode", + "plugin_version", + "follow_current_version", + "created_at", + "updated_at", + } + unique_constraints = { + constraint["name"]: tuple(constraint["column_names"]) + for constraint in inspector.get_unique_constraints("plugininstance") + } + assert unique_constraints["uq_plugininstance_instance_id"] == ("instance_id",) + check_constraints = { + constraint["name"] + for constraint in inspector.get_check_constraints("plugininstance") + } + assert "ck_plugininstance_mode" in check_constraints + indexes = {index["name"] for index in inspector.get_indexes("plugininstance")} + assert "ix_plugininstance_source_plugin_id" in indexes + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + rows = connection.execute(sa.select(table)).mappings().all() + assert len(rows) == 1 + row = rows[0] + assert row["instance_id"] == "DemoPluginWork" + assert row["source_plugin_id"] == "DemoPlugin" + assert row["plugin_name"] == "工作实例" + assert row["mode"] == "virtual" + assert row["plugin_version"] == "1.2.0" + assert row["follow_current_version"] is False + + legacy_row = connection.execute( + sa.select(SystemConfig.value).where(SystemConfig.key == "PluginInstances") + ).scalar_one() + assert legacy_row == { + "DemoPluginWork": { + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + "plugin_name": "工作实例", + "follow_current_version": False, + "plugin_version": "1.2.0", + }, + } + + migration.downgrade() + assert "plugininstance" not in sa.inspect(connection).get_table_names() + assert connection.execute( + sa.select(SystemConfig.value).where(SystemConfig.key == "PluginInstances") + ).scalar_one() == legacy_row + + migration.upgrade() + restored = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + restored_rows = connection.execute(sa.select(restored)).mappings().all() + assert len(restored_rows) == 1 + assert restored_rows[0]["instance_id"] == "DemoPluginWork" + + +def test_plugin_instance_migration_migrates_legacy_list_payload(monkeypatch) -> None: + """历史列表载荷同样应逐条搬入新表。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + SystemConfig.__table__.create(connection) + _seed_legacy_key( + connection, + [ + {"instance_id": "DemoPluginWork", "source_plugin_id": "DemoPlugin"}, + {"instance_id": "DemoPluginBackup", "source_plugin_id": "DemoPlugin"}, + ], + ) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + instance_ids = { + row["instance_id"] + for row in connection.execute(sa.select(table)).mappings().all() + } + assert instance_ids == {"DemoPluginWork", "DemoPluginBackup"} + + +def test_plugin_instance_migration_skips_malformed_legacy_entries(monkeypatch) -> None: + """缺失必填字段或非字典条目必须被跳过,不得中断迁移。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + SystemConfig.__table__.create(connection) + _seed_legacy_key( + connection, + [ + {"instance_id": "MissingSource"}, + {"source_plugin_id": "DemoPlugin"}, + "not-a-dict", + {"instance_id": "DemoPluginWork", "source_plugin_id": "DemoPlugin"}, + ], + ) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + rows = connection.execute(sa.select(table)).mappings().all() + assert [row["instance_id"] for row in rows] == ["DemoPluginWork"] + + +def test_plugin_instance_migration_without_legacy_key_creates_empty_table( + monkeypatch, +) -> None: + """旧键缺失或为空时应正常建表,不产生任何数据行。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + SystemConfig.__table__.create(connection) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + + assert "plugininstance" in sa.inspect(connection).get_table_names() + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + assert connection.execute(sa.select(table)).mappings().all() == [] + + +def test_plugin_instance_migration_accepts_fresh_current_schema(monkeypatch) -> None: + """create_all 已建当前表时重复升级不得创建冲突对象。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + SystemConfig.__table__.create(connection) + PluginInstance.__table__.create(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + assert { + column["name"] + for column in sa.inspect(connection).get_columns("plugininstance") + } == {column.name for column in PluginInstance.__table__.columns} diff --git a/tests/test_plugin_instance_version.py b/tests/test_plugin_instance_version.py new file mode 100644 index 0000000000..3460a57a55 --- /dev/null +++ b/tests/test_plugin_instance_version.py @@ -0,0 +1,567 @@ +"""插件实例版本绑定字段、加载期版本解析与已生效版本登记测试。""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.runtime.extensions.plugin.lifecycle import PluginLifecycle +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.storage import ( + PluginInstanceDirectory, + PluginInstanceStore, + PluginStorage, +) +from app.runtime.extensions.plugin.version import ( + plugin_version_dir_name, + write_plugin_versions_manifest, +) +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus +from app.schemas.types import SystemConfigKey + + +def _logger() -> SimpleNamespace: + """提供加载器与生命周期测试所需的最小日志端口。""" + return SimpleNamespace( + debug=lambda *_args: None, + info=lambda *_args: None, + warning=lambda *_args: None, + error=lambda *_args: None, + ) + + +def _make_loader(plugins_root: Path, **overrides) -> PluginLoader: + """构造只需最小日志端口的加载器实例,可覆盖本体版本绑定查询端口。""" + return PluginLoader( + plugins_root=plugins_root, + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + log=_logger(), + **overrides, + ) + + +class _FakeInstanceDirectory: + """进程内插件实例描述符表,记录写入调用轨迹供断言使用。""" + + def __init__(self) -> None: + self.records: dict[str, PluginInstance] = {} + self.saved: list[PluginInstance] = [] + self.deleted: list[str] = [] + + def get(self, instance_id: str) -> PluginInstance | None: + """按实例 ID 读取单条描述。""" + return self.records.get(instance_id) + + def list_all(self) -> list[PluginInstance]: + """列出全部描述。""" + return list(self.records.values()) + + def list_by_source(self, source_plugin_id: str) -> list[PluginInstance]: + """按源插件 ID 列出其全部描述。""" + return [ + record + for record in self.records.values() + if record.source_plugin_id == source_plugin_id + ] + + def save(self, instance: PluginInstance) -> None: + """新增或更新一条描述并记录调用轨迹。""" + self.records[instance.instance_id] = instance + self.saved.append(instance) + + def delete(self, instance_id: str) -> bool: + """删除一条描述并记录调用轨迹。""" + removed = self.records.pop(instance_id, None) + if removed is not None: + self.deleted.append(instance_id) + return removed is not None + + def port(self) -> PluginInstanceDirectory: + """构造绑定到本实例状态的持久化端口。""" + return PluginInstanceDirectory( + get=self.get, + list_all=self.list_all, + list_by_source=self.list_by_source, + save=self.save, + delete=self.delete, + ) + + +def _write_version( + plugins_root: Path, + plugin_id: str, + version: str, + *, + class_name: str = "Versioned", + marker: str, +) -> Path: + """写入一个版本目录的最小可加载源码,marker 用于区分不同版本被加载到。""" + plugin_root = plugins_root / plugin_id + version_dir = plugin_root / plugin_version_dir_name(version) + version_dir.mkdir(parents=True) + (version_dir / "__init__.py").write_text( + f"class {class_name}:\n" + f" plugin_version = {version!r}\n" + f" marker = {marker!r}\n" + " def init_plugin(self, config=None):\n" + " pass\n", + encoding="utf-8", + ) + return version_dir + + +def _write_manifest(plugin_root: Path, entries: list[tuple[str, str]], current: str | None) -> None: + """写入版本元信息文件。""" + versions = [ + { + "version": version, + "directory": directory, + "installed_at": "2026-01-01T00:00:00+00:00", + "source": "test", + } + for version, directory in entries + ] + write_plugin_versions_manifest(plugin_root, versions, current) + + +@pytest.fixture(autouse=True) +def _isolate_plugin_modules(): + """回收测试期间手动导入的临时插件模块,避免污染其它用例的模块缓存。""" + before = set(sys.modules) + yield + for name in set(sys.modules) - before: + if name.startswith("app.plugins."): + sys.modules.pop(name, None) + + +# 一、PluginInstance 版本绑定字段 + + +def test_plugin_instance_defaults_to_no_effective_version_and_follows_current(): + """新建实例默认未生效任何版本且跟随插件当前版本。""" + instance = PluginInstance(instance_id="DemoWork", source_plugin_id="Demo") + + assert instance.plugin_version is None + assert instance.follow_current_version is True + + +def test_instance_store_tolerates_legacy_payload_missing_version_fields(): + """兜底导入时,存量数据缺少版本绑定字段仍按默认值容错反序列化,不丢弃该实例。""" + values = { + SystemConfigKey.PluginInstances: { + "DemoWork": { + "instance_id": "DemoWork", + "source_plugin_id": "Demo", + "plugin_name": "工作实例", + } + } + } + storage = PluginStorage(read=values.get, write=lambda key, value: values.__setitem__(key, value)) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + + instance = store.get("DemoWork") + + assert instance is not None + assert instance.plugin_version is None + assert instance.follow_current_version is True + + +# 二、PluginInstanceStore 已生效版本登记 + + +def test_record_effective_version_writes_when_changed(): + """成功启动的版本与已登记值不同时才写入持久化。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + store.save(PluginInstance(instance_id="DemoWork", source_plugin_id="Demo")) + + store.record_effective_version("DemoWork", "1.2.0") + + assert store.get("DemoWork").plugin_version == "1.2.0" + + +def test_record_effective_version_skips_write_when_unchanged(): + """已生效版本与本次值相同时不产生新的持久化写入。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + directory = _FakeInstanceDirectory() + store = PluginInstanceStore(storage=lambda: storage, directory=directory.port) + store.save(PluginInstance(instance_id="DemoWork", source_plugin_id="Demo", plugin_version="1.2.0")) + directory.saved.clear() + + store.record_effective_version("DemoWork", "1.2.0") + + assert directory.saved == [] + + +def test_record_effective_version_ignores_ids_without_instance_descriptor(): + """物理插件的分身与本体都没有实例描述时静默跳过,不因找不到实例而报错。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + + store.record_effective_version("PhysicalPlugin", "1.0.0") + + assert store.all() == {} + + +# 五、PluginInstanceStore 本体版本绑定 + + +def test_host_binding_is_isolated_from_clone_views(): + """本体的版本绑定记录不出现在 all()/get()/for_source() 这些分身专用视图里。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + + store.save_host( + PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + follow_current_version=False, + plugin_version="1.0.0", + ) + ) + + assert store.get("DemoPlugin") is None + assert store.all() == {} + assert store.for_source("DemoPlugin") == [] + host = store.get_host("DemoPlugin") + assert host is not None + assert host.mode == "host" + assert host.follow_current_version is False + assert host.plugin_version == "1.0.0" + + +def test_host_binding_defaults_to_none_when_never_bound(): + """从未显式绑定过版本的本体读取为 None,而不是一条隐式默认记录。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + + assert store.get_host("DemoPlugin") is None + + +def test_record_effective_version_updates_existing_host_binding(): + """本体已被绑定过版本时,成功启动会更新其已生效版本。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + store.save_host(PluginInstance(instance_id="DemoPlugin", source_plugin_id="DemoPlugin")) + + store.record_effective_version("DemoPlugin", "1.2.0") + + assert store.get_host("DemoPlugin").plugin_version == "1.2.0" + + +def test_record_effective_version_does_not_create_host_binding_on_first_start(): + """本体从未被显式绑定过版本时,成功启动不会隐式创建一条绑定记录。""" + storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None) + store = PluginInstanceStore(storage=lambda: storage, directory=_FakeInstanceDirectory().port) + + store.record_effective_version("DemoPlugin", "1.2.0") + + assert store.get_host("DemoPlugin") is None + + +def test_store_bootstraps_legacy_instances_once_when_table_is_empty(): + """新表为空而旧 systemconfig 单键非空时,首次访问按旧内容导入一次,且不重复导入。""" + values = { + SystemConfigKey.PluginInstances: { + "DemoWork": {"instance_id": "DemoWork", "source_plugin_id": "Demo"}, + } + } + storage = PluginStorage(read=values.get, write=lambda key, value: values.__setitem__(key, value)) + directory = _FakeInstanceDirectory() + store = PluginInstanceStore(storage=lambda: storage, directory=directory.port) + + first = store.all() + values[SystemConfigKey.PluginInstances] = { + "DemoWork": {"instance_id": "DemoWork", "source_plugin_id": "Demo"}, + "DemoHome": {"instance_id": "DemoHome", "source_plugin_id": "Demo"}, + } + second = store.all() + + assert set(first) == {"DemoWork"} + assert set(second) == {"DemoWork"} + assert len(directory.saved) == 1 + + +def test_store_skips_bootstrap_import_when_table_already_has_rows(): + """新表已有内容时不再导入旧 systemconfig 单键,避免覆盖已迁移或已改动的数据。""" + values = { + SystemConfigKey.PluginInstances: { + "DemoWork": {"instance_id": "DemoWork", "source_plugin_id": "Demo"}, + } + } + storage = PluginStorage(read=values.get, write=lambda key, value: values.__setitem__(key, value)) + directory = _FakeInstanceDirectory() + directory.records["DemoHome"] = PluginInstance( + instance_id="DemoHome", source_plugin_id="Demo" + ) + store = PluginInstanceStore(storage=lambda: storage, directory=directory.port) + + instances = store.all() + + assert set(instances) == {"DemoHome"} + assert directory.saved == [] + + +# 三、加载期版本解析与失败回退 + + +def test_load_instance_follows_manifest_current_version_by_default(tmp_path: Path): + """跟随当前版本时,加载器按版本元信息登记的当前版本取源码。""" + _write_version(tmp_path, "versioned", "1.0.0", marker="old") + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest( + tmp_path / "versioned", + [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], + current="2.0.0", + ) + + instance = PluginInstance(instance_id="VersionedWork", source_plugin_id="versioned") + plugins = _make_loader(tmp_path).load_instance( + instance, lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert plugins[0].plugin_version == "2.0.0" + assert plugins[0].marker == "new" + + +def test_load_instance_uses_bound_version_when_not_following_current(tmp_path: Path): + """不跟随当前版本时,加载器固定使用绑定版本的源码,而不是清单当前版本。""" + _write_version(tmp_path, "versioned", "1.0.0", marker="old") + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest( + tmp_path / "versioned", + [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], + current="2.0.0", + ) + + instance = PluginInstance( + instance_id="VersionedWork", + source_plugin_id="versioned", + follow_current_version=False, + plugin_version="1.0.0", + ) + plugins = _make_loader(tmp_path).load_instance( + instance, lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert plugins[0].plugin_version == "1.0.0" + assert plugins[0].marker == "old" + + +def test_load_instance_falls_back_to_current_when_bound_directory_missing(tmp_path: Path): + """绑定版本目录已不存在时回落当前版本,而不是直接判定加载失败。""" + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest(tmp_path / "versioned", [("2.0.0", "v2_0_0")], current="2.0.0") + + instance = PluginInstance( + instance_id="VersionedWork", + source_plugin_id="versioned", + follow_current_version=False, + plugin_version="9.9.9", + ) + warnings: list[str] = [] + loader = _make_loader(tmp_path) + loader._logger.warning = warnings.append + + plugins = loader.load_instance(instance, lambda candidate: hasattr(candidate, "init_plugin")) + + assert plugins[0].plugin_version == "2.0.0" + assert warnings and "9.9.9" in warnings[0] + + +def test_load_instance_explicit_version_overrides_binding(tmp_path: Path): + """显式指定版本时优先于实例自身绑定,供失败回退重试指定一个具体版本。""" + _write_version(tmp_path, "versioned", "1.0.0", marker="old") + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest( + tmp_path / "versioned", + [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], + current="2.0.0", + ) + + instance = PluginInstance(instance_id="VersionedWork", source_plugin_id="versioned") + plugins = _make_loader(tmp_path).load_instance( + instance, + lambda candidate: hasattr(candidate, "init_plugin"), + version="1.0.0", + ) + + assert plugins[0].plugin_version == "1.0.0" + assert plugins[0].marker == "old" + + +def test_load_host_follows_manifest_current_version_without_binding(tmp_path: Path): + """源插件本体从未被绑定过版本时,加载器按版本元信息登记的当前版本取源码。""" + _write_version(tmp_path, "versioned", "1.0.0", marker="old") + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest( + tmp_path / "versioned", + [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], + current="2.0.0", + ) + + plugins = _make_loader(tmp_path).load( + "versioned", ["versioned"], lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert plugins[0].plugin_version == "2.0.0" + assert plugins[0].marker == "new" + + +def test_load_host_uses_bound_version_when_not_following_current(tmp_path: Path): + """本体绑定为不跟随当前版本时,加载器固定使用绑定版本的源码。""" + _write_version(tmp_path, "versioned", "1.0.0", marker="old") + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest( + tmp_path / "versioned", + [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], + current="2.0.0", + ) + binding = PluginInstance( + instance_id="versioned", + source_plugin_id="versioned", + mode="host", + follow_current_version=False, + plugin_version="1.0.0", + ) + loader = _make_loader(tmp_path, host_binding=lambda plugin_id: binding if plugin_id == "versioned" else None) + + plugins = loader.load( + "versioned", ["versioned"], lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert plugins[0].plugin_version == "1.0.0" + assert plugins[0].marker == "old" + + +def test_load_host_falls_back_to_current_when_bound_directory_missing(tmp_path: Path): + """本体绑定的版本目录已不存在时回落当前版本,而不是让本体加载失败。""" + _write_version(tmp_path, "versioned", "2.0.0", marker="new") + _write_manifest(tmp_path / "versioned", [("2.0.0", "v2_0_0")], current="2.0.0") + binding = PluginInstance( + instance_id="versioned", + source_plugin_id="versioned", + mode="host", + follow_current_version=False, + plugin_version="9.9.9", + ) + warnings: list[str] = [] + loader = _make_loader(tmp_path, host_binding=lambda plugin_id: binding if plugin_id == "versioned" else None) + loader._logger.warning = warnings.append + + plugins = loader.load( + "versioned", ["versioned"], lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert plugins[0].plugin_version == "2.0.0" + assert warnings and "9.9.9" in warnings[0] + + +# 四、PluginLifecycle 启动成功后登记已生效版本 + + +def _build_lifecycle(*, load_plugins, record_instance_version) -> PluginLifecycle: + """构造只暴露版本登记端口的最小生命周期实例。""" + from app.runtime.extensions.plugin.database import PluginDatabase + + return PluginLifecycle( + classes={}, + running={}, + load_plugins=load_plugins, + installed_plugins=lambda: ["VersionedWork"], + plugin_config=lambda _plugin_id: {}, + auth_checker=lambda _plugin: True, + clear_modules=lambda _plugin_id: None, + clear_tools=lambda: None, + enable_events=lambda _plugin: None, + disable_events=lambda _plugin: None, + runtime_status_writer=lambda _plugin_id, _status: None, + database=lambda: PluginDatabase(), + log=_logger(), + event_sender=lambda *_args, **_kwargs: None, + record_instance_version=record_instance_version, + ) + + +def _versioned_plugin_class(version: str): + """构造声明指定版本号的最小插件类。""" + return type( + "VersionedWork", + (), + { + "plugin_name": "版本化实例", + "plugin_version": version, + "init_plugin": lambda self, _config=None: None, + "get_state": staticmethod(lambda: True), + }, + ) + + +def test_lifecycle_records_loaded_class_version_on_successful_start(): + """启动成功后把已加载类声明的版本登记为该实例的已生效版本。""" + recorded: list[tuple[str, str]] = [] + lifecycle = _build_lifecycle( + load_plugins=lambda _pid, _installed, _check, _version=None: [ + _versioned_plugin_class("2.0.0") + ], + record_instance_version=lambda instance_id, version: recorded.append( + (instance_id, version) + ), + ) + + results = lifecycle.start("VersionedWork") + + assert results["VersionedWork"] == PluginRuntimeStatus.ACTIVE + assert recorded == [("VersionedWork", "2.0.0")] + + +def test_lifecycle_does_not_record_version_when_start_fails(): + """启动失败时不登记任何版本,保持已生效版本原值不变。""" + recorded: list[tuple[str, str]] = [] + + def _raising_init(_self, _config=None): + raise RuntimeError("boom") + + broken_class = type( + "VersionedWork", + (), + { + "plugin_name": "版本化实例", + "plugin_version": "2.0.0", + "init_plugin": _raising_init, + "get_state": staticmethod(lambda: True), + }, + ) + lifecycle = _build_lifecycle( + load_plugins=lambda _pid, _installed, _check, _version=None: [broken_class], + record_instance_version=lambda instance_id, version: recorded.append( + (instance_id, version) + ), + ) + + results = lifecycle.start("VersionedWork") + + assert results["VersionedWork"] == PluginRuntimeStatus.LOAD_FAILED + assert recorded == [] + + +def test_lifecycle_start_threads_explicit_version_to_load_plugins(): + """显式 version 参数会原样传给 load_plugins,供版本切换重试使用。""" + seen_versions: list = [] + + def _load_plugins(_pid, _installed, _check, version=None): + seen_versions.append(version) + return [_versioned_plugin_class(version or "2.0.0")] + + lifecycle = _build_lifecycle( + load_plugins=_load_plugins, + record_instance_version=lambda *_args: None, + ) + + lifecycle.start("VersionedWork", version="1.0.0") + + assert seen_versions == ["1.0.0"] diff --git a/tests/test_plugin_lifecycle_status.py b/tests/test_plugin_lifecycle_status.py index e0dfca3a67..2b543d99ab 100644 --- a/tests/test_plugin_lifecycle_status.py +++ b/tests/test_plugin_lifecycle_status.py @@ -40,7 +40,7 @@ def _lifecycle( lifecycle = PluginLifecycle( classes=classes, running=running, - load_plugins=lambda _plugin_id, _installed, _check: list(plugins), + load_plugins=lambda _plugin_id, _installed, _check, _version=None: list(plugins), installed_plugins=lambda: ["DemoPlugin"], plugin_config=lambda _plugin_id: {}, auth_checker=lambda _plugin: auth, diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index 47b2074c87..e4efc96616 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -14,9 +14,14 @@ from app.runtime.extensions.plugin.manager import PluginManager from app.runtime.extensions.plugin.paths import PluginPathResolver from app.runtime.extensions.plugin.system import get_plugin_system +from app.runtime.extensions.plugin.version import ( + plugin_version_dir_name, + write_plugin_versions_manifest, +) from app.scheduler import reconcile as scheduler_reconcile from app.scheduler.facade import Scheduler from app.scheduler.registry import ExecutionRegistry +from app.schemas.plugin import PluginInstance from app.schemas.types import EventType, SystemConfigKey @@ -512,6 +517,138 @@ def test_runtime_federated_asset_change_does_not_copy_or_reload( reload_spy.assert_not_called() +def _write_versioned_remote_entry(plugin_root: Path, version: str) -> Path: + """在插件根目录下的指定版本目录中写入一个最小联邦入口文件。 + + :param plugin_root: 插件源码根目录 + :param version: 版本号,决定版本目录名 + :return: 写入的 remoteEntry.js 路径 + """ + remote_entry = plugin_root / plugin_version_dir_name(version) / "dist" / "remoteEntry.js" + remote_entry.parent.mkdir(parents=True) + remote_entry.write_text("export default {}\n", encoding="utf-8") + return remote_entry + + +def test_federated_change_resolves_within_the_current_version_directory( + tmp_path, + monkeypatch, +) -> None: + """插件源码按版本分目录时,联邦产物变化按插件当前版本目录识别,而不是插件根目录。""" + plugins_root = tmp_path / "app" / "plugins" + plugin_root = plugins_root / "demoplugin" + remote_entry = _write_versioned_remote_entry(plugin_root, "1.0.0") + write_plugin_versions_manifest( + plugin_root, + [ + { + "version": "1.0.0", + "directory": plugin_version_dir_name("1.0.0"), + "installed_at": "2026-01-01T00:00:00+00:00", + "source": "test", + } + ], + current="1.0.0", + ) + _configure_local_watcher(monkeypatch, tmp_path, tmp_path / "unused-local-repository", set()) + resolver = PluginPathResolver( + runtime_root=plugins_root, + running=lambda: {"DemoPlugin": SimpleNamespace(get_render_mode=lambda: ("vue", "dist"))}, + system=get_plugin_system, + strict_system_version=lambda: False, + get_instance=lambda _plugin_id: None, + log=Mock(), + ) + + assert resolver.federated_change(remote_entry) == ("DemoPlugin", None, True) + + +def test_federated_change_ignores_a_non_current_version_directory( + tmp_path, + monkeypatch, +) -> None: + """产物变化发生在没有被加载的旧版本目录中时不识别,避免误判成入口就绪。""" + plugins_root = tmp_path / "app" / "plugins" + plugin_root = plugins_root / "demoplugin" + stale_remote_entry = _write_versioned_remote_entry(plugin_root, "1.0.0") + _write_versioned_remote_entry(plugin_root, "2.0.0") + write_plugin_versions_manifest( + plugin_root, + [ + { + "version": "1.0.0", + "directory": plugin_version_dir_name("1.0.0"), + "installed_at": "2026-01-01T00:00:00+00:00", + "source": "test", + }, + { + "version": "2.0.0", + "directory": plugin_version_dir_name("2.0.0"), + "installed_at": "2026-02-01T00:00:00+00:00", + "source": "test", + }, + ], + current="2.0.0", + ) + _configure_local_watcher(monkeypatch, tmp_path, tmp_path / "unused-local-repository", set()) + resolver = PluginPathResolver( + runtime_root=plugins_root, + running=lambda: {"DemoPlugin": SimpleNamespace(get_render_mode=lambda: ("vue", "dist"))}, + system=get_plugin_system, + strict_system_version=lambda: False, + get_instance=lambda _plugin_id: None, + log=Mock(), + ) + + assert resolver.federated_change(stale_remote_entry) is None + + +def test_federated_change_resolves_the_running_ids_bound_version_directory( + tmp_path, + monkeypatch, +) -> None: + """联邦产物路径按 ``get_instance`` 端口解析出的绑定版本识别,而不是插件当前版本。""" + plugins_root = tmp_path / "app" / "plugins" + plugin_root = plugins_root / "demoplugin" + pinned_remote_entry = _write_versioned_remote_entry(plugin_root, "1.0.0") + _write_versioned_remote_entry(plugin_root, "2.0.0") + write_plugin_versions_manifest( + plugin_root, + [ + { + "version": "1.0.0", + "directory": plugin_version_dir_name("1.0.0"), + "installed_at": "2026-01-01T00:00:00+00:00", + "source": "test", + }, + { + "version": "2.0.0", + "directory": plugin_version_dir_name("2.0.0"), + "installed_at": "2026-02-01T00:00:00+00:00", + "source": "test", + }, + ], + current="2.0.0", + ) + pinned_instance = PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=False, + ) + _configure_local_watcher(monkeypatch, tmp_path, tmp_path / "unused-local-repository", set()) + resolver = PluginPathResolver( + runtime_root=plugins_root, + running=lambda: {"DemoPlugin": SimpleNamespace(get_render_mode=lambda: ("vue", "dist"))}, + system=get_plugin_system, + strict_system_version=lambda: False, + get_instance=lambda _plugin_id: pinned_instance, + log=Mock(), + ) + + assert resolver.federated_change(pinned_remote_entry) == ("DemoPlugin", None, True) + + def test_local_requirements_change_still_does_not_sync_or_reload( tmp_path, monkeypatch, @@ -813,6 +950,7 @@ def test_runtime_python_change_reloads_without_local_repository_sync( running=lambda: plugin_manager.running_plugins, system=get_plugin_system, strict_system_version=lambda: False, + get_instance=lambda _plugin_id: None, log=Mock(), ) sync_spy = Mock() @@ -867,7 +1005,12 @@ def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch): assert set(scheduler._jobs) == {"DemoPlugin_new"} service = scheduler._jobs["DemoPlugin_new"] - assert service["func"] is current_func + # 定时服务回调经 wrap_for_plugin_instance 包了一层日志实例上下文绑定, + # 不再是原始 callable 本身;用 __wrapped__ 核对包装前后是同一个函数, + # 并实测调用确实透传到原始 Mock。 + assert service["func"].__wrapped__ is current_func + service["func"](marker="check") + current_func.assert_called_once_with(marker="check") assert service["kwargs"] == {"marker": "new"} assert set(backend.jobs) == {"DemoPlugin_new"} registered_job = backend.jobs["DemoPlugin_new"] diff --git a/tests/test_plugin_log_control_endpoints.py b/tests/test_plugin_log_control_endpoints.py new file mode 100644 index 0000000000..775923dc18 --- /dev/null +++ b/tests/test_plugin_log_control_endpoints.py @@ -0,0 +1,176 @@ +"""插件实例日志等级查询与设置接口测试。""" + +from __future__ import annotations + +import inspect +from datetime import datetime, timezone + +import pytest +from fastapi import HTTPException + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.endpoints import pluginversion as pluginversion_endpoint +from app.api.endpoints.pluginversion import ( + clear_plugin_instance_log_level, + plugin_instance_log_levels, + set_plugin_instance_log_level, +) +from app.schemas.plugin import PluginInstanceLogLevelUpdateRequest + + +def _depends_default(func, parameter_name: str): + """取出端点函数指定参数的 FastAPI Depends 默认值。""" + return inspect.signature(func).parameters[parameter_name].default + + +def _manager(**methods): + """按方法名快速拼装一个鸭子类型的 Manager 替身。""" + return type("Manager", (), methods)() + + +def test_all_endpoints_require_superuser_dependency(): + """三个日志等级端点都要求超级管理员,不能被低权限用户直接调用。""" + for func in ( + plugin_instance_log_levels, + set_plugin_instance_log_level, + clear_plugin_instance_log_level, + ): + depends = _depends_default(func, "_") + assert depends.dependency is get_current_active_superuser + + +def test_get_returns_manager_levels_wrapped_in_plugin_overview(monkeypatch): + """查询接口把 Manager 组装好的等级列表包进插件级总览对象,而不是裸列表。 + + 响应模型直接裸露列表会被宿主分页契约门禁判定为集合接口、要求声明分页参数; + 插件实例数量始终很小,参照 plugin.versions.get 包一层 plugin_id + instances。 + """ + levels = [ + { + "instance_id": "DemoPlugin", + "configured_level": None, + "expires_at": None, + "effective_level": "INFO", + }, + { + "instance_id": "DemoPluginWork", + "configured_level": "DEBUG", + "expires_at": None, + "effective_level": "DEBUG", + }, + ] + manager = _manager(get_plugin_instance_log_levels=lambda self, _plugin_id: levels) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = plugin_instance_log_levels("DemoPlugin", None) + + assert result.success is True + assert result.data == {"plugin_id": "DemoPlugin", "instances": levels} + + +def test_get_reports_missing_plugin_as_404(monkeypatch): + """插件不存在时查询接口返回 404,而不是让异常穿透或吞掉。""" + + def _raise(_plugin_id): + raise LookupError("插件 Missing 不存在") + + manager = _manager( + get_plugin_instance_log_levels=lambda self, plugin_id: _raise(plugin_id) + ) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + plugin_instance_log_levels("Missing", None) + + assert excinfo.value.status_code == 404 + + +def test_put_delegates_to_manager_and_reports_success(monkeypatch): + """设置请求原样转交给 Manager,携带等级与失效时间。""" + calls: list = [] + manager = _manager( + set_plugin_instance_log_level=lambda self, *a, **kw: calls.append((a, kw)) + ) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + expires_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + + result = set_plugin_instance_log_level( + "DemoPlugin", + "DemoPluginWork", + PluginInstanceLogLevelUpdateRequest(level="DEBUG", expires_at=expires_at), + None, + ) + + assert result.success is True + assert calls == [(("DemoPlugin", "DemoPluginWork", "DEBUG", expires_at), {})] + + +def test_put_reports_missing_plugin_or_instance_as_404(monkeypatch): + """未知插件或实例时设置接口返回 404。""" + + def _raise(*_a, **_kw): + raise LookupError("插件实例 Missing 不存在") + + manager = _manager(set_plugin_instance_log_level=_raise) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + set_plugin_instance_log_level( + "DemoPlugin", + "Missing", + PluginInstanceLogLevelUpdateRequest(level="DEBUG"), + None, + ) + + assert excinfo.value.status_code == 404 + + +def test_put_reports_invalid_level_as_400(monkeypatch): + """非法等级时设置接口返回 400。""" + + def _raise(*_a, **_kw): + raise ValueError("不支持的日志等级:LOUD") + + manager = _manager(set_plugin_instance_log_level=_raise) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + set_plugin_instance_log_level( + "DemoPlugin", + "DemoPlugin", + PluginInstanceLogLevelUpdateRequest(level="LOUD"), + None, + ) + + assert excinfo.value.status_code == 400 + + +def test_delete_delegates_to_manager_and_is_idempotent(monkeypatch): + """清除请求原样转交给 Manager,重复调用同样返回成功。""" + calls: list = [] + manager = _manager( + clear_plugin_instance_log_level=lambda self, *a: calls.append(a) + ) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + first = clear_plugin_instance_log_level("DemoPlugin", "DemoPluginWork", None) + second = clear_plugin_instance_log_level("DemoPlugin", "DemoPluginWork", None) + + assert first.success is True + assert second.success is True + assert calls == [("DemoPlugin", "DemoPluginWork"), ("DemoPlugin", "DemoPluginWork")] + + +def test_delete_reports_missing_plugin_or_instance_as_404(monkeypatch): + """未知插件或实例时清除接口返回 404。""" + + def _raise(*_a): + raise LookupError("插件实例 Missing 不存在") + + manager = _manager(clear_plugin_instance_log_level=_raise) + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + clear_plugin_instance_log_level("DemoPlugin", "Missing", None) + + assert excinfo.value.status_code == 404 diff --git a/tests/test_plugin_manager_governance.py b/tests/test_plugin_manager_governance.py index 2214337550..6fa5d0e730 100644 --- a/tests/test_plugin_manager_governance.py +++ b/tests/test_plugin_manager_governance.py @@ -56,6 +56,7 @@ def test_plugin_manager_only_consumes_injected_runtime_factory() -> None: "PluginRegistry", "PluginSyncService", "PluginToolCatalog", + "PluginVersionBinding", } assert calls.count("_plugin_runtime_factory") == 1 @@ -183,6 +184,7 @@ def test_plugin_runtime_is_the_only_owner_aggregate() -> None: "registry", "sync", "system", + "version_binding", } <= fields assert len(_parse(package_root).body) == 1 diff --git a/tests/test_plugin_package_manager.py b/tests/test_plugin_package_manager.py index 5efbf39acd..876c4e78a2 100644 --- a/tests/test_plugin_package_manager.py +++ b/tests/test_plugin_package_manager.py @@ -1,3 +1,5 @@ +import errno +import os import shutil from pathlib import Path from types import SimpleNamespace @@ -5,9 +7,14 @@ import pytest +from app.adapters.system.plugin import package as plugin_package_module from app.adapters.system.plugin.package import PluginPackageManager from app.runtime.dependencies.native import LoadedNativeDependencySnapshot +_swap_staged_plugin_content = ( + PluginPackageManager._PluginPackageManager__swap_staged_plugin_content +) + def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager: """构造使用隔离运行目录和事务目录的插件包管理器。""" @@ -112,6 +119,7 @@ def test_file_list_download_rejects_paths_outside_plugin_root( [{"path": remote_path, "download_url": "https://example.invalid/file"}], "owner/repo", package_version, + plugin_root / "demoplugin", ) assert result == (False, "插件文件路径无效") @@ -143,6 +151,7 @@ async def test_async_file_list_download_rejects_path_outside_plugin_root( ], "owner/repo", "v2", + tmp_path / "plugins" / "demoplugin", ) assert result == (False, "插件文件路径无效") @@ -170,12 +179,13 @@ async def test_file_list_download_rejects_traversal_directory_names( async_query, ) item = {"name": "..", "download_url": None} + dest_root = tmp_path / "plugins" / "demoplugin" assert manager._PluginPackageManager__download_files( - "DemoPlugin", [item], "owner/repo" + "DemoPlugin", [item], "owner/repo", None, dest_root ) == (False, "插件目录路径无效") assert await manager._PluginPackageManager__async_download_files( - "DemoPlugin", [item], "owner/repo" + "DemoPlugin", [item], "owner/repo", None, dest_root ) == (False, "插件目录路径无效") sync_query.assert_not_called() async_query.assert_not_awaited() @@ -204,12 +214,13 @@ async def test_file_list_download_maps_valid_paths_into_injected_plugin_root( "path": "plugins.v2/demoplugin/nested/file.py", "download_url": "https://example.invalid/file", } + dest_root = plugin_root / "demoplugin" assert manager._PluginPackageManager__download_files( - "DemoPlugin", [item], "owner/repo", "v2" + "DemoPlugin", [item], "owner/repo", "v2", dest_root ) == (True, "") assert await manager._PluginPackageManager__async_download_files( - "DemoPlugin", [item], "owner/repo", "v2" + "DemoPlugin", [item], "owner/repo", "v2", dest_root ) == (True, "") assert (plugin_root / "demoplugin" / "nested" / "file.py").read_text( encoding="utf-8" @@ -458,3 +469,143 @@ def test_clone_rewrites_python_and_federation_assets(monkeypatch, tmp_path): assert 'plugin_config_prefix = "demopluginblue_"' in clone_source assert "is_clone = True" in clone_source assert (clone_dir / "dist" / "demopluginblue.js").is_file() + + +def _fake_rename_failing_staging_source(staging_dir: Path): + """构造只让暂存目录改名失败(模拟 EXDEV)、其余改名走真实实现的 os.rename 替身。""" + real_rename = os.rename + + def fake_rename(src, dst): + if str(src) == str(staging_dir): + raise OSError(errno.EXDEV, "Invalid cross-device link") + return real_rename(src, dst) + + return fake_rename + + +def _fake_copytree_leaves_partial_content_then_fails(marker_name: str, message: str): + """构造先写入半份新内容再抛错的 copytree 替身,模拟复制中途磁盘写满。""" + + def fake_copytree(src, dst, **_kwargs): + Path(dst).mkdir(parents=True, exist_ok=True) + (Path(dst) / marker_name).write_text("partial", encoding="utf-8") + raise OSError(errno.ENOSPC, message) + + return fake_copytree + + +def test_swap_staged_plugin_content_keeps_old_content_intact_when_cross_device_copy_fails( + monkeypatch, tmp_path, +): + """跨设备退化为复制时复制中途失败,旧内容必须逐字节完好,不留半份新内容,原始异常向上抛出。""" + staging_dir = tmp_path / "staging" + final_dir = tmp_path / "plugins" / "demoplugin" + staging_dir.mkdir(parents=True) + (staging_dir / "new.txt").write_text("new-payload", encoding="utf-8") + final_dir.mkdir(parents=True) + (final_dir / "old.txt").write_text("old-payload", encoding="utf-8") + + monkeypatch.setattr( + plugin_package_module.os, + "rename", + _fake_rename_failing_staging_source(staging_dir), + ) + monkeypatch.setattr( + plugin_package_module.shutil, + "copytree", + _fake_copytree_leaves_partial_content_then_fails("partial.txt", "No space left on device"), + ) + + with pytest.raises(OSError) as exc_info: + _swap_staged_plugin_content(staging_dir, final_dir) + + assert exc_info.value.errno == errno.ENOSPC + assert final_dir.is_dir() + assert (final_dir / "old.txt").read_text(encoding="utf-8") == "old-payload" + assert not (final_dir / "new.txt").exists() + assert not (final_dir / "partial.txt").exists() + assert list(final_dir.parent.glob(f".{final_dir.name}.previous-*")) == [] + + +def test_swap_staged_plugin_content_leaves_nothing_behind_when_target_never_existed( + monkeypatch, tmp_path, +): + """全新版本目录首次落盘时复制中途失败,目标目录必须完全回到不存在状态,不留半成品。""" + staging_dir = tmp_path / "staging" + final_dir = tmp_path / "plugins" / "demoplugin" / "v3_0_0" + staging_dir.mkdir(parents=True) + (staging_dir / "new.txt").write_text("new-payload", encoding="utf-8") + + monkeypatch.setattr( + plugin_package_module.os, + "rename", + _fake_rename_failing_staging_source(staging_dir), + ) + monkeypatch.setattr( + plugin_package_module.shutil, + "copytree", + _fake_copytree_leaves_partial_content_then_fails("partial.txt", "No space left on device"), + ) + + with pytest.raises(OSError): + _swap_staged_plugin_content(staging_dir, final_dir) + + assert not final_dir.exists() + assert list(final_dir.parent.glob(f".{final_dir.name}.previous-*")) == [] + + +def test_swap_staged_plugin_content_atomic_rename_success_path_is_unaffected(tmp_path): + """同一文件系统内可原子改名时保持一次改名换入,不会改走复制加删除的退化路径。""" + staging_dir = tmp_path / "staging" + final_dir = tmp_path / "plugins" / "demoplugin" + staging_dir.mkdir(parents=True) + (staging_dir / "new.txt").write_text("new-payload", encoding="utf-8") + final_dir.mkdir(parents=True) + (final_dir / "old.txt").write_text("old-payload", encoding="utf-8") + + _swap_staged_plugin_content(staging_dir, final_dir) + + assert not staging_dir.exists() + assert (final_dir / "new.txt").read_text(encoding="utf-8") == "new-payload" + assert not (final_dir / "old.txt").exists() + assert list(final_dir.parent.glob(f".{final_dir.name}.previous-*")) == [] + + +def test_swap_staged_plugin_content_preserves_recovery_material_when_rollback_itself_fails( + monkeypatch, tmp_path, +): + """回滚换回旧内容也失败时不吞掉原始异常,且旧内容以恢复材料形式保留而不是被清空。""" + staging_dir = tmp_path / "staging" + final_dir = tmp_path / "plugins" / "demoplugin" + staging_dir.mkdir(parents=True) + (staging_dir / "new.txt").write_text("new-payload", encoding="utf-8") + final_dir.mkdir(parents=True) + (final_dir / "old.txt").write_text("old-payload", encoding="utf-8") + + real_rename = os.rename + previous_prefix = str(final_dir.parent / f".{final_dir.name}.previous-") + + def fake_rename(src, dst): + src_str = str(src) + if src_str == str(staging_dir): + raise OSError(errno.EXDEV, "Invalid cross-device link") + if src_str.startswith(previous_prefix): + raise OSError(errno.EACCES, "Permission denied") + return real_rename(src, dst) + + monkeypatch.setattr(plugin_package_module.os, "rename", fake_rename) + monkeypatch.setattr( + plugin_package_module.shutil, + "copytree", + _fake_copytree_leaves_partial_content_then_fails("partial.txt", "No space left on device"), + ) + + with pytest.raises(OSError) as exc_info: + _swap_staged_plugin_content(staging_dir, final_dir) + + assert exc_info.value.errno == errno.ENOSPC + assert isinstance(exc_info.value.__cause__, OSError) + assert exc_info.value.__cause__.errno == errno.EACCES + preserved = list(final_dir.parent.glob(f".{final_dir.name}.previous-*")) + assert len(preserved) == 1 + assert (preserved[0] / "old.txt").read_text(encoding="utf-8") == "old-payload" diff --git a/tests/test_plugin_version_binding.py b/tests/test_plugin_version_binding.py new file mode 100644 index 0000000000..ff866f58a9 --- /dev/null +++ b/tests/test_plugin_version_binding.py @@ -0,0 +1,712 @@ +"""插件已装版本总览与实例版本绑定切换测试。""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Iterator + +import pytest + +from app.foundation.singleton import Singleton +from app.runtime.extensions.plugin.binding import PluginVersionBinding +from app.runtime.extensions.plugin.manager import PluginManager +from app.runtime.extensions.plugin.version import ( + plugin_version_dir_name, + read_plugin_versions_manifest, + write_plugin_versions_manifest, +) +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus + + +def _logger() -> SimpleNamespace: + """提供绑定服务测试所需的最小日志端口。""" + return SimpleNamespace( + debug=lambda *_args: None, + info=lambda *_args: None, + warning=lambda *_args: None, + error=lambda *_args: None, + ) + + +def _write_version_dir(plugins_root: Path, plugin_id: str, version: str) -> Path: + """在插件根目录下创建一个空的版本目录。""" + version_dir = plugins_root / plugin_id / plugin_version_dir_name(version) + version_dir.mkdir(parents=True) + return version_dir + + +def _write_manifest( + plugin_root: Path, entries: list[tuple[str, str]], current: str | None +) -> None: + """写入版本元信息文件。""" + versions = [ + { + "version": version, + "directory": directory, + "installed_at": "2026-01-01T00:00:00+00:00", + "source": "test", + } + for version, directory in entries + ] + write_plugin_versions_manifest(plugin_root, versions, current) + + +def _stamp_installed_at(plugin_root: Path, stamps: dict[str, str]) -> None: + """把已装版本清单里各版本的登记时间改写为指定值,消除真实时钟带来的顺序不确定性。""" + manifest = read_plugin_versions_manifest(plugin_root) + for entry in manifest["versions"]: + if entry["version"] in stamps: + entry["installed_at"] = stamps[entry["version"]] + write_plugin_versions_manifest(plugin_root, manifest["versions"], manifest["current"]) + + +class _Harness: + """组装 PluginVersionBinding 依赖并记录调用轨迹的测试脚手架。""" + + def __init__( + self, + *, + plugins_root: Path, + instances: dict[str, PluginInstance] | None = None, + host_instances: dict[str, PluginInstance] | None = None, + plugin_exists: bool = True, + known_plugin_ids: set[str] | None = None, + start_results: dict | None = None, + multi_version_blockers: list[str] | None = None, + running_ids: set[str] | None = None, + instances_for_source_error: Exception | None = None, + ) -> None: + self.plugins_root = plugins_root + self.instances: dict[str, PluginInstance] = dict(instances or {}) + self.host_instances: dict[str, PluginInstance] = dict(host_instances or {}) + self.saved: list[PluginInstance] = [] + self.saved_hosts: list[PluginInstance] = [] + self.stopped: list[str] = [] + self.start_calls: list[tuple[str, str | None]] = [] + self._start_results = start_results or {} + self._plugin_exists_flag = plugin_exists + # None 保持旧语义:不论查询哪个 ID 都直接返回 plugin_exists 这一个布尔值; + # 只有显式传入 known_plugin_ids 时才按 ID 精确判定,供需要区分「已知插件」 + # 与「任意未知 ID」的用例使用(例如本体解析回退路径)。 + self._known_plugin_ids = known_plugin_ids + self._multi_version_blockers_result = ( + [] if multi_version_blockers is None else multi_version_blockers + ) + self.multi_version_blockers_calls: list[tuple[str, list[Path]]] = [] + self._running_ids = running_ids or set() + self._instances_for_source_error = instances_for_source_error + self.logger = _logger() + self.service = PluginVersionBinding( + plugins_root=plugins_root, + plugin_exists=self._plugin_exists, + get_instance=self.instances.get, + instances_for_source=self._instances_for_source, + save_instance=self._save_instance, + get_host_instance=self.host_instances.get, + save_host_instance=self._save_host_instance, + running=lambda: {plugin_id: object() for plugin_id in self._running_ids}, + start=self._start, + stop=self.stopped.append, + multi_version_blockers=self._multi_version_blockers, + log=self.logger, + ) + + def _plugin_exists(self, plugin_id: str) -> bool: + if self._known_plugin_ids is not None: + return self._plugin_exists_flag and plugin_id in self._known_plugin_ids + return self._plugin_exists_flag + + def _instances_for_source(self, source_plugin_id: str) -> list[PluginInstance]: + if self._instances_for_source_error is not None: + raise self._instances_for_source_error + return [ + instance + for instance in self.instances.values() + if instance.source_plugin_id == source_plugin_id + ] + + def _save_instance(self, instance: PluginInstance) -> None: + self.instances[instance.instance_id] = instance + self.saved.append(instance) + + def _save_host_instance(self, instance: PluginInstance) -> None: + self.host_instances[instance.instance_id] = instance + self.saved_hosts.append(instance) + + def _start(self, instance_id: str, version: str | None) -> dict: + self.start_calls.append((instance_id, version)) + status = self._start_results.get(version, PluginRuntimeStatus.ACTIVE) + return {instance_id: status} + + def _multi_version_blockers(self, plugin_id: str, source_dirs: list[Path]) -> list[str]: + self.multi_version_blockers_calls.append((plugin_id, list(source_dirs))) + return self._multi_version_blockers_result + + +# 一、已装版本总览 + + +def test_overview_lists_installed_versions_and_instance_bindings(tmp_path: Path): + """总览含已装版本落盘信息与各实例的版本绑定和运行状态。""" + plugin_root = tmp_path / "demoplugin" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_manifest( + plugin_root, [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="2.0.0" + ) + work = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=False, + ) + home = PluginInstance(instance_id="DemoPluginHome", source_plugin_id="DemoPlugin") + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": work, "DemoPluginHome": home}, + running_ids={"DemoPluginWork"}, + ) + + overview = harness.service.overview("DemoPlugin") + + assert overview["plugin_id"] == "DemoPlugin" + assert overview["current_version"] == "2.0.0" + assert [item["version"] for item in overview["installed_versions"]] == [ + "1.0.0", + "2.0.0", + ] + assert overview["installed_versions"][1]["is_current"] is True + assert overview["installed_versions"][0]["is_current"] is False + bindings = {item["instance_id"]: item for item in overview["instances"]} + assert bindings["DemoPluginWork"] == { + "instance_id": "DemoPluginWork", + "plugin_version": "1.0.0", + "follow_current_version": False, + "running": True, + "is_host": False, + "is_default_target": False, + } + assert bindings["DemoPluginHome"]["running"] is False + assert bindings["DemoPluginHome"]["follow_current_version"] is True + assert bindings["DemoPluginHome"]["is_host"] is False + assert bindings["DemoPluginHome"]["is_default_target"] is False + assert bindings["DemoPlugin"] == { + "instance_id": "DemoPlugin", + "plugin_version": None, + "follow_current_version": True, + "running": False, + "is_host": True, + "is_default_target": False, + } + assert overview["instances"][0]["is_host"] is True + + +def test_overview_raises_lookup_error_for_unknown_plugin(tmp_path: Path): + """插件不存在时抛出 LookupError,不返回空壳总览掩盖问题。""" + harness = _Harness(plugins_root=tmp_path, plugin_exists=False) + + with pytest.raises(LookupError): + harness.service.overview("Missing") + + +# 二、实例版本绑定切换 + + +def test_set_instance_version_switches_to_pinned_version(tmp_path: Path): + """唯一实例切到已安装的目标版本时直接成功,不触发并存检查。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + instance = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": instance}, + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is True + assert message == "DemoPluginWork" + assert harness.instances["DemoPluginWork"].follow_current_version is False + assert harness.stopped == ["DemoPluginWork"] + assert harness.start_calls == [("DemoPluginWork", "2.0.0")] + assert harness.multi_version_blockers_calls == [] + + +def test_set_instance_version_rejects_uninstalled_target(tmp_path: Path): + """目标版本未安装时拒绝切换,不停止也不启动实例。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + instance = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + harness = _Harness(plugins_root=tmp_path, instances={"DemoPluginWork": instance}) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="9.9.9" + ) + + assert success is False + assert "未安装版本 9.9.9" in message + assert harness.stopped == [] + assert harness.start_calls == [] + + +def test_set_instance_version_requires_target_when_not_following(tmp_path: Path): + """不跟随当前版本却未指定目标版本时拒绝切换。""" + instance = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + harness = _Harness(plugins_root=tmp_path, instances={"DemoPluginWork": instance}) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version=None + ) + + assert success is False + assert "必须指定目标版本" in message + + +def test_set_instance_version_returns_failure_for_unknown_instance(tmp_path: Path): + """实例不存在、且该 ID 也不是已知插件本体时直接返回失败,不产生任何副作用。""" + harness = _Harness(plugins_root=tmp_path, known_plugin_ids=set()) + + success, message = harness.service.set_instance_version( + "Missing", follow_current_version=True + ) + + assert success is False + assert "不存在" in message + assert harness.saved == [] + + +def test_set_instance_version_rejects_when_would_create_unsupported_coexistence( + tmp_path: Path, +): + """切换会让插件多版本并存且写法不支持时拒绝,且不改动任何状态。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_manifest( + tmp_path / "demoplugin", [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="1.0.0" + ) + target = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + sibling = PluginInstance(instance_id="DemoPluginHome", source_plugin_id="DemoPlugin") + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": target, "DemoPluginHome": sibling}, + multi_version_blockers=["存在自引用绝对导入"], + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is False + assert "多版本并存" in message + assert harness.saved == [] + assert harness.stopped == [] + assert harness.start_calls == [] + assert harness.multi_version_blockers_calls[0][0] == "demoplugin" + + +def test_set_instance_version_allows_coexistence_when_no_blockers_found(tmp_path: Path): + """并存会发生但静态扫描未命中阻断时允许切换。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_manifest( + tmp_path / "demoplugin", [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="1.0.0" + ) + target = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + sibling = PluginInstance(instance_id="DemoPluginHome", source_plugin_id="DemoPlugin") + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": target, "DemoPluginHome": sibling}, + multi_version_blockers=[], + ) + + success, _message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is True + assert harness.multi_version_blockers_calls != [] + + +def test_set_instance_version_falls_back_to_previous_effective_version(tmp_path: Path): + """目标版本启动失败时保持已生效版本不动,并以该版本重新启动完成回退。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + instance = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=False, + ) + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": instance}, + start_results={"2.0.0": PluginRuntimeStatus.LOAD_FAILED, "1.0.0": PluginRuntimeStatus.ACTIVE}, + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is False + assert "已回退到原版本 1.0.0" in message + assert harness.start_calls == [ + ("DemoPluginWork", "2.0.0"), + ("DemoPluginWork", "1.0.0"), + ] + + +def test_set_instance_version_fails_without_retry_when_no_fallback_available(tmp_path: Path): + """从未成功启动过时没有可回退的版本,启动失败即直接判定失败,不做二次尝试。""" + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + instance = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": instance}, + start_results={"2.0.0": PluginRuntimeStatus.LOAD_FAILED}, + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is False + assert "没有可回退" not in message # 面向用户的消息保持简洁 + assert harness.start_calls == [("DemoPluginWork", "2.0.0")] + + +def test_set_instance_version_reports_failure_when_fallback_also_fails(tmp_path: Path): + """目标版本和回退版本均启动失败时,两次尝试都发生且给出明确失败信息。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + instance = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=False, + ) + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": instance}, + start_results={ + "2.0.0": PluginRuntimeStatus.LOAD_FAILED, + "1.0.0": PluginRuntimeStatus.LOAD_FAILED, + }, + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is False + assert "同样失败" in message + assert harness.start_calls == [ + ("DemoPluginWork", "2.0.0"), + ("DemoPluginWork", "1.0.0"), + ] + + +def test_set_instance_version_switch_to_follow_current_does_not_retry(tmp_path: Path): + """切回跟随当前版本失败时按单次尝试语义处理,不做回退重试。""" + instance = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=False, + ) + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": instance}, + start_results={None: PluginRuntimeStatus.LOAD_FAILED}, + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=True + ) + + assert success is False + assert "跟随当前版本失败" in message + assert harness.start_calls == [("DemoPluginWork", None)] + assert harness.instances["DemoPluginWork"].follow_current_version is True + + +def test_set_instance_version_applies_to_source_plugin_host(tmp_path: Path): + """instance_id 等于源插件 ID 且该插件存在时按本体解析,写回本体端口而非分身端口。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + harness = _Harness(plugins_root=tmp_path) + + success, message = harness.service.set_instance_version( + "DemoPlugin", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is True + assert message == "DemoPlugin" + assert harness.saved == [] + assert harness.saved_hosts[-1].instance_id == "DemoPlugin" + assert harness.saved_hosts[-1].mode == "host" + assert harness.host_instances["DemoPlugin"].follow_current_version is False + assert harness.stopped == ["DemoPlugin"] + assert harness.start_calls == [("DemoPlugin", "2.0.0")] + + +def test_set_instance_version_updates_existing_host_binding(tmp_path: Path): + """本体已有版本绑定记录时,切换沿用同一条记录原地更新,不当作新分身处理。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + existing_host = PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + mode="host", + follow_current_version=False, + plugin_version="1.0.0", + ) + harness = _Harness(plugins_root=tmp_path, host_instances={"DemoPlugin": existing_host}) + + success, _message = harness.service.set_instance_version( + "DemoPlugin", follow_current_version=True + ) + + assert success is True + assert harness.host_instances["DemoPlugin"].follow_current_version is True + assert harness.saved == [] + + +def test_creates_version_coexistence_detects_divergence_from_pinned_host(tmp_path: Path): + """本体被钉在某版本时,分身切到另一版本仍会被判定为制造多版本并存。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_manifest( + tmp_path / "demoplugin", [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="1.0.0" + ) + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + pinned_host = PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + mode="host", + follow_current_version=False, + plugin_version="1.0.0", + ) + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": clone}, + host_instances={"DemoPlugin": pinned_host}, + multi_version_blockers=["存在自引用绝对导入"], + ) + + success, message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="2.0.0" + ) + + assert success is False + assert "多版本并存" in message + assert harness.multi_version_blockers_calls != [] + + +def test_creates_version_coexistence_uses_host_actual_pinned_version_not_manifest_current( + tmp_path: Path, +): + """并存判定按本体实际绑定版本核算,而不是无条件假设本体运行清单当前版本。 + + 清单当前版本是 2.0.0,但本体已被钉在 1.0.0;分身切到与本体实际绑定一致的 + 1.0.0 时不应被判定为制造并存——旧实现无条件把清单当前版本当作本体的运行 + 版本,会在这种场景下把这次完全安全的切换误判为并存并拒绝。 + """ + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_manifest( + tmp_path / "demoplugin", [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="2.0.0" + ) + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + pinned_host = PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + mode="host", + follow_current_version=False, + plugin_version="1.0.0", + ) + harness = _Harness( + plugins_root=tmp_path, + instances={"DemoPluginWork": clone}, + host_instances={"DemoPlugin": pinned_host}, + multi_version_blockers=["存在自引用绝对导入"], + ) + + success, _message = harness.service.set_instance_version( + "DemoPluginWork", follow_current_version=False, plugin_version="1.0.0" + ) + + assert success is True + assert harness.multi_version_blockers_calls == [] + + +# 三、版本回收 + + +def test_recycle_versions_raises_lookup_error_for_unknown_plugin(tmp_path: Path): + """插件不存在时抛出 LookupError,不静默返回空回收结果。""" + harness = _Harness(plugins_root=tmp_path, plugin_exists=False) + + with pytest.raises(LookupError): + harness.service.recycle_versions("Missing") + + +def test_recycle_versions_protects_both_effective_and_expected_versions(tmp_path: Path): + """引用集合并入已生效版本与按跟随开关解析出的期望版本,两者都受保护。 + + 四个已装版本按登记时间排列,默认保留窗口(2)只覆盖最近的 3.0.0 与 + 4.0.0;1.0.0 既不是当前版本也不在保留窗口内,唯一能保住它的就是「被 + 实例引用」这条判据——实例跟随当前版本(期望版本 3.0.0),但已生效版本 + 仍是上次成功启动时的旧版本 1.0.0,二者必须都并入引用集合。 + """ + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_version_dir(tmp_path, "demoplugin", "3.0.0") + _write_version_dir(tmp_path, "demoplugin", "4.0.0") + plugin_root = tmp_path / "demoplugin" + _write_manifest( + plugin_root, + [ + ("1.0.0", "v1_0_0"), + ("2.0.0", "v2_0_0"), + ("3.0.0", "v3_0_0"), + ("4.0.0", "v4_0_0"), + ], + current="3.0.0", + ) + _stamp_installed_at( + plugin_root, + { + "1.0.0": "2020-01-01T00:00:00+00:00", + "2.0.0": "2020-02-01T00:00:00+00:00", + "3.0.0": "2020-03-01T00:00:00+00:00", + "4.0.0": "2020-04-01T00:00:00+00:00", + }, + ) + following = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_version="1.0.0", + follow_current_version=True, + ) + harness = _Harness(plugins_root=tmp_path, instances={"DemoPluginWork": following}) + + outcome = harness.service.recycle_versions("DemoPlugin") + + assert outcome["removed"] == ["2.0.0"] + assert (plugin_root / "v1_0_0").is_dir() + assert (plugin_root / "v3_0_0").is_dir() + assert (plugin_root / "v4_0_0").is_dir() + assert not (plugin_root / "v2_0_0").exists() + + +def test_recycle_versions_protects_hosts_effective_version_with_no_clones(tmp_path: Path): + """引用集合同样纳入本体的已生效版本,即便该插件没有任何分身实例。 + + 四个已装版本,保留窗口只覆盖最近的 3.0.0 与 4.0.0;本体跟随当前版本(期望 + 版本 3.0.0),但已生效版本仍是上次成功启动时的旧版本 1.0.0——遗漏本体会 + 误删它正在用的版本。 + """ + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + _write_version_dir(tmp_path, "demoplugin", "2.0.0") + _write_version_dir(tmp_path, "demoplugin", "3.0.0") + _write_version_dir(tmp_path, "demoplugin", "4.0.0") + plugin_root = tmp_path / "demoplugin" + _write_manifest( + plugin_root, + [ + ("1.0.0", "v1_0_0"), + ("2.0.0", "v2_0_0"), + ("3.0.0", "v3_0_0"), + ("4.0.0", "v4_0_0"), + ], + current="3.0.0", + ) + _stamp_installed_at( + plugin_root, + { + "1.0.0": "2020-01-01T00:00:00+00:00", + "2.0.0": "2020-02-01T00:00:00+00:00", + "3.0.0": "2020-03-01T00:00:00+00:00", + "4.0.0": "2020-04-01T00:00:00+00:00", + }, + ) + host = PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + mode="host", + plugin_version="1.0.0", + follow_current_version=True, + ) + harness = _Harness(plugins_root=tmp_path, host_instances={"DemoPlugin": host}) + + outcome = harness.service.recycle_versions("DemoPlugin") + + assert outcome["removed"] == ["2.0.0"] + assert (plugin_root / "v1_0_0").is_dir() + assert (plugin_root / "v3_0_0").is_dir() + assert (plugin_root / "v4_0_0").is_dir() + assert not (plugin_root / "v2_0_0").exists() + + +def test_recycle_versions_propagates_referenced_version_collection_failures(tmp_path: Path): + """收集引用集合失败时直接向上抛出,不能按空集继续回收。""" + _write_version_dir(tmp_path, "demoplugin", "1.0.0") + plugin_root = tmp_path / "demoplugin" + _write_manifest(plugin_root, [("1.0.0", "v1_0_0")], current="1.0.0") + harness = _Harness( + plugins_root=tmp_path, + instances_for_source_error=RuntimeError("实例存储不可用"), + ) + + with pytest.raises(RuntimeError, match="实例存储不可用"): + harness.service.recycle_versions("DemoPlugin") + + # 收集失败时不能触发任何删除,版本目录必须原样保留。 + assert (plugin_root / "v1_0_0").is_dir() + + +# 四、批量回收调用方(PluginManager 逐插件隔离失败) + + +@pytest.fixture +def plugin_manager() -> Iterator[PluginManager]: + """构造隔离的插件管理器单例,测试后归还,避免污染其它用例。""" + Singleton._instances.pop((PluginManager, (), frozenset()), None) + manager = PluginManager() + yield manager + Singleton._instances.pop((PluginManager, (), frozenset()), None) + + +def test_recycle_all_plugin_versions_skips_instances_and_isolates_failures( + plugin_manager: PluginManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """批量回收跳过虚拟实例 ID,且单个插件失败不阻断其余插件的回收。""" + monkeypatch.setattr( + plugin_manager, "get_plugin_ids", lambda: ["PluginA", "PluginB", "CloneWork"] + ) + clone_instance = PluginInstance(instance_id="CloneWork", source_plugin_id="PluginA") + monkeypatch.setattr( + plugin_manager, + "get_plugin_instance", + lambda plugin_id: clone_instance if plugin_id == "CloneWork" else None, + ) + recycle_calls: list[str] = [] + + def fake_recycle_plugin_versions(plugin_id: str) -> dict: + """PluginB 的回收总是失败,其余插件按原样返回回收结果。""" + recycle_calls.append(plugin_id) + if plugin_id == "PluginB": + raise RuntimeError("boom") + return {"removed": [], "kept": {}} + + monkeypatch.setattr( + plugin_manager, "recycle_plugin_versions", fake_recycle_plugin_versions + ) + + results = plugin_manager.recycle_all_plugin_versions() + + assert recycle_calls == ["PluginA", "PluginB"] + assert results == {"PluginA": {"removed": [], "kept": {}}} diff --git a/tests/test_plugin_version_install.py b/tests/test_plugin_version_install.py new file mode 100644 index 0000000000..0d422c1421 --- /dev/null +++ b/tests/test_plugin_version_install.py @@ -0,0 +1,1234 @@ +"""插件版本注册、存量布局迁移、安装期多版本并存拒绝与安装落盘版本目录测试。""" + +from __future__ import annotations + +import errno +import io +import os +import shutil +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.adapters.system.plugin import package as plugin_package_module +from app.adapters.system.plugin.package import PluginPackageManager +from app.runtime.extensions.plugin import version as plugin_version_module +from app.runtime.extensions.plugin.version import ( + PLUGIN_FALLBACK_VERSION, + migrate_legacy_plugin_layout, + plugin_version_dir_name, + plugin_version_dirs, + read_plugin_versions_manifest, + register_plugin_version, + remove_plugin_installed_version, +) +from app.startup.composition.plugin import ( + _register_plugin_install_version as register_plugin_install_version, +) +from app.startup.composition.plugin import ( + _reject_incompatible_plugin_version_switch as reject_incompatible_plugin_version_switch, +) +from app.startup.composition.plugin import ( + _resolve_plugin_install_target as resolve_plugin_install_target, +) +from app.startup.composition.plugin import ( + _rollback_plugin_install_version as rollback_plugin_install_version, +) + + +def _write_flat_plugin(plugin_root: Path, *, class_name: str, version: str | None) -> None: + """写入一个平铺布局的最小插件源码。 + + :param plugin_root: 插件源码根目录 + :param class_name: 插件主类名 + :param version: 类体内声明的 plugin_version 值;为 None 时不声明版本号 + """ + plugin_root.mkdir(parents=True, exist_ok=True) + version_line = f" plugin_version = {version!r}\n" if version else "" + (plugin_root / "__init__.py").write_text( + f"class {class_name}:\n{version_line} plugin_name = {class_name!r}\n", + encoding="utf-8", + ) + + +# 一、版本注册 + + +def test_register_plugin_version_writes_manifest_and_sets_current(tmp_path: Path) -> None: + """注册一个版本后元信息登记该版本并置为当前版本,返回其版本目录名与登记前的当前版本号。""" + plugin_root = tmp_path / "registered" + + dir_name, previous_current = register_plugin_version(plugin_root, "1.2.0", source="local") + + assert dir_name == "v1_2_0" + assert previous_current is None + manifest = read_plugin_versions_manifest(plugin_root) + assert manifest["current"] == "1.2.0" + assert manifest["versions"] == [ + { + "version": "1.2.0", + "directory": "v1_2_0", + "installed_at": manifest["versions"][0]["installed_at"], + "source": "local", + } + ] + + +def test_register_plugin_version_replaces_existing_entry_for_the_same_version( + tmp_path: Path, +) -> None: + """重新注册同一版本号时替换旧条目,不产生重复记录。""" + plugin_root = tmp_path / "reregistered" + register_plugin_version(plugin_root, "1.0.0", source="local") + + register_plugin_version(plugin_root, "1.0.0", source="migrated") + + manifest = read_plugin_versions_manifest(plugin_root) + assert len(manifest["versions"]) == 1 + assert manifest["versions"][0]["source"] == "migrated" + + +def test_register_plugin_version_keeps_other_versions_and_switches_current( + tmp_path: Path, +) -> None: + """注册第二个版本后两条记录并存,当前版本切到新注册的版本,返回登记前的当前版本号。""" + plugin_root = tmp_path / "dual" + register_plugin_version(plugin_root, "1.0.0", source="local") + + _, previous_current = register_plugin_version(plugin_root, "2.0.0", source="local") + + assert previous_current == "1.0.0" + manifest = read_plugin_versions_manifest(plugin_root) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "2.0.0"} + assert manifest["current"] == "2.0.0" + + +def test_register_plugin_version_rejects_illegal_version(tmp_path: Path) -> None: + """版本号非法时拒绝注册,不写入任何元信息。""" + plugin_root = tmp_path / "illegal" + + with pytest.raises(ValueError): + register_plugin_version(plugin_root, "1_0_0", source="local") + + assert read_plugin_versions_manifest(plugin_root) == {} + + +# 二、存量布局迁移 + + +def test_legacy_layout_is_migrated_into_a_version_dir(tmp_path: Path) -> None: + """存量平铺布局按声明的版本号迁移到版本目录并写入元信息。""" + plugin_root = tmp_path / "legacy" + _write_flat_plugin(plugin_root, class_name="LegacyPlugin", version="1.3.0") + (plugin_root / "utils.py").write_text("VALUE = 3\n", encoding="utf-8") + + migrated = migrate_legacy_plugin_layout(plugin_root) + + assert migrated == plugin_root / "v1_3_0" + assert (plugin_root / "v1_3_0" / "__init__.py").is_file() + assert (plugin_root / "v1_3_0" / "utils.py").is_file() + assert not (plugin_root / "__init__.py").exists() + manifest = read_plugin_versions_manifest(plugin_root) + assert manifest["current"] == "1.3.0" + assert manifest["versions"][0]["directory"] == "v1_3_0" + assert manifest["versions"][0]["source"] == "migrated" + + +def test_legacy_layout_without_declared_version_uses_the_fallback_version( + tmp_path: Path, +) -> None: + """读不到版本号的存量插件按兜底版本号迁移,不阻断迁移。""" + plugin_root = tmp_path / "noversion" + _write_flat_plugin(plugin_root, class_name="NoVersionPlugin", version=None) + + migrated = migrate_legacy_plugin_layout(plugin_root) + + assert migrated == plugin_root / plugin_version_dir_name(PLUGIN_FALLBACK_VERSION) + assert read_plugin_versions_manifest(plugin_root)["current"] == PLUGIN_FALLBACK_VERSION + + +def test_migration_is_a_no_op_when_nothing_needs_migrating(tmp_path: Path) -> None: + """已经是版本化布局、没有平铺源码也没有残留中转目录时,迁移不做任何事。""" + plugin_root = tmp_path / "done" + version_dir = plugin_root / "v1_0_0" + version_dir.mkdir(parents=True) + (version_dir / "__init__.py").write_text("class DonePlugin:\n pass\n", encoding="utf-8") + register_plugin_version(plugin_root, "1.0.0", source="local") + before = read_plugin_versions_manifest(plugin_root) + + assert migrate_legacy_plugin_layout(plugin_root) is None + assert read_plugin_versions_manifest(plugin_root) == before + + +def test_stray_entries_are_not_migrated_as_a_version(tmp_path: Path) -> None: + """已迁移插件目录下的杂项条目不会被当成待迁移的存量版本。""" + plugin_root = tmp_path / "stray" + version_dir = plugin_root / "v1_0_0" + version_dir.mkdir(parents=True) + (version_dir / "__init__.py").write_text("class StrayPlugin:\n pass\n", encoding="utf-8") + register_plugin_version(plugin_root, "1.0.0", source="local") + (plugin_root / ".DS_Store").write_text("x", encoding="utf-8") + + assert migrate_legacy_plugin_layout(plugin_root) is None + assert not (plugin_root / plugin_version_dir_name(PLUGIN_FALLBACK_VERSION)).exists() + + +def test_interrupted_migration_is_resumed(tmp_path: Path) -> None: + """上次迁移中断留下的中转目录会被发现并续做。""" + plugin_root = tmp_path / "resumed" + _write_flat_plugin(plugin_root, class_name="ResumedPlugin", version="2.5.0") + staging = tmp_path / "resumed.migrating-deadbeef" + os.rename(plugin_root, staging) + + migrated = migrate_legacy_plugin_layout(plugin_root) + + assert migrated == plugin_root / "v2_5_0" + assert (plugin_root / "v2_5_0" / "__init__.py").is_file() + assert not staging.exists() + assert read_plugin_versions_manifest(plugin_root)["current"] == "2.5.0" + + +def test_cross_device_rename_abandons_migration_and_keeps_flat_layout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """跨设备无法原子改名时放弃迁移,存量布局原样保留,不做复制加删除的非原子回退。""" + plugin_root = tmp_path / "crossdev" + _write_flat_plugin(plugin_root, class_name="CrossDevPlugin", version="1.0.0") + + def _refuse(*_args: object, **_kwargs: object) -> None: + """模拟跨设备改名失败。""" + raise OSError(errno.EXDEV, "Invalid cross-device link") + + monkeypatch.setattr(plugin_version_module.os, "rename", _refuse) + + assert migrate_legacy_plugin_layout(plugin_root) == plugin_root + assert (plugin_root / "__init__.py").is_file() + assert not (plugin_root / "v1_0_0").exists() + assert not read_plugin_versions_manifest(plugin_root) + assert list(tmp_path.iterdir()) == [plugin_root] + + +# 三、安装期多版本并存拒绝 + + +def test_first_version_install_is_not_rejected(tmp_path: Path) -> None: + """插件此前未安装任何版本时,本地安装不受并存检查影响。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="1.0.0") + + rejection = reject_incompatible_plugin_version_switch( + "DemoPlugin", + tmp_path / "app" / "plugins" / "demoplugin", + source_dir, + ) + + assert rejection is None + + +def test_same_version_resync_is_not_rejected(tmp_path: Path) -> None: + """新旧声明版本号相同时不是在装第二个版本,不触发并存检查。""" + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "from app.plugins.demoplugin.utils import helper\n" + "class DemoPlugin:\n plugin_version = '1.0.0'\n", + encoding="utf-8", + ) + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="1.0.0") + + rejection = reject_incompatible_plugin_version_switch("DemoPlugin", plugin_dir, source_dir) + + assert rejection is None + + +def test_version_switch_with_self_referential_import_is_rejected(tmp_path: Path) -> None: + """已装版本存在自引用绝对导入时,切换到不同版本号被拒绝并给出可读原因。""" + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "from app.plugins.demoplugin.utils import helper\n" + "class DemoPlugin:\n plugin_version = '1.0.0'\n", + encoding="utf-8", + ) + (plugin_dir / "utils.py").write_text("def helper():\n pass\n", encoding="utf-8") + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="2.0.0") + + rejection = reject_incompatible_plugin_version_switch("DemoPlugin", plugin_dir, source_dir) + + assert rejection is not None + assert "自引用" in rejection + assert "1.0.0" in rejection and "2.0.0" in rejection + + +def test_version_switch_with_shared_base_model_is_rejected(tmp_path: Path) -> None: + """已装版本在宿主共享声明基类上建模时,切换到不同版本号被拒绝。""" + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "from app.db import Base\n" + "class MyData(Base):\n pass\n" + "class DemoPlugin:\n plugin_version = '1.0.0'\n", + encoding="utf-8", + ) + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="2.0.0") + + rejection = reject_incompatible_plugin_version_switch("DemoPlugin", plugin_dir, source_dir) + + assert rejection is not None + assert "共享声明基类" in rejection + + +def test_version_switch_without_blockers_is_allowed(tmp_path: Path) -> None: + """新旧版本都只用相对 import、不建模共享基类时,版本切换不被拒绝。""" + plugin_dir = tmp_path / "app" / "plugins" / "demoplugin" + _write_flat_plugin(plugin_dir, class_name="DemoPlugin", version="1.0.0") + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="2.0.0") + + rejection = reject_incompatible_plugin_version_switch("DemoPlugin", plugin_dir, source_dir) + + assert rejection is None + + +# 四、包适配器接线:安装流程实际调用了注入的并存检查端口 + + +def test_package_manager_defaults_to_a_no_op_version_switch_guard(tmp_path: Path) -> None: + """未装配并存检查端口时按不拦截退化,保持今天的单版本覆盖安装行为。""" + manager = PluginPackageManager(plugin_root=tmp_path / "app" / "plugins") + + assert ( + manager._version_switch_guard("DemoPlugin", tmp_path / "any", tmp_path / "other") + is None + ) + + +def test_local_install_rejects_when_the_injected_guard_blocks_the_switch( + tmp_path: Path, +) -> None: + """本地安装在写入前调用注入的并存检查端口,命中拒绝时不触碰已装内容。""" + calls: list[tuple[str, Path, Path]] = [] + + def _reject(pid: str, plugin_dir: Path, source_dir: Path) -> str | None: + """记录调用参数并总是拒绝,验证接入点确实转发到了注入端口。""" + calls.append((pid, plugin_dir, source_dir)) + return "拒绝理由" + + plugins_root = tmp_path / "app" / "plugins" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="2.0.0") + source_port = Mock() + source_port.parse_local_repo_url.return_value = "DemoPlugin" + source_port.parse_local_repo_path.return_value = None + source_port.parse_local_repo_package_version.return_value = None + source_port.get_local_plugin_candidate.return_value = {"path": str(source_dir)} + source_port.check_plugin_system_version.return_value = (True, "") + manager = PluginPackageManager( + source=source_port, + plugin_root=plugins_root, + version_switch_guard=_reject, + ) + plugin_dir = plugins_root / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("stable", encoding="utf-8") + + success, message = manager.install_local_raw("DemoPlugin", repo_url="local://demoplugin") + + assert success is False + assert message == "拒绝理由" + assert calls and calls[0][0] == "DemoPlugin" + assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "stable" + + +# 五、安装落盘版本目录 + + +def _versioned_manager( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + source: object | None = None, +) -> tuple[PluginPackageManager, Path]: + """构造接了真实版本目录解析与登记端口的包管理器,运行目录隔离在 tmp_path。 + + :param monkeypatch: pytest monkeypatch 夹具 + :param tmp_path: 隔离运行目录的临时根 + :param source: 市场来源端口;为空时用一个空 Mock 占位 + :return: (包管理器, 插件根目录) + """ + plugin_root = tmp_path / "app" / "plugins" + settings = SimpleNamespace( + ROOT_PATH=tmp_path, + TEMP_PATH=tmp_path / "temp", + CONFIG_PATH=tmp_path / "config", + REPO_GITHUB_HEADERS=lambda repo: {}, + ) + monkeypatch.setattr( + "app.adapters.system.plugin.package.get_runtime_setting", + lambda key: getattr(settings, key), + ) + manager = PluginPackageManager( + source=source or Mock(), + plugin_root=plugin_root, + version_switch_guard=reject_incompatible_plugin_version_switch, + install_target_resolver=resolve_plugin_install_target, + install_version_registrar=register_plugin_install_version, + install_version_rollback=rollback_plugin_install_version, + ) + return manager, plugin_root + + +def _local_source_port(source_dir: Path) -> Mock: + """构造一个只声明本地安装所需方法的市场来源端口替身。""" + source_port = Mock() + source_port.parse_local_repo_url.return_value = "DemoPlugin" + source_port.parse_local_repo_path.return_value = None + source_port.parse_local_repo_package_version.return_value = None + source_port.get_local_plugin_candidate.return_value = {"path": str(source_dir)} + source_port.check_plugin_system_version.return_value = (True, "") + return source_port + + +def _zip_bytes(files: dict[str, str]) -> bytes: + """把文件名到文本内容的映射打包为内存中的 zip 字节串。""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return buffer.getvalue() + + +def test_local_install_lands_in_version_directory_and_registers_source( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """本地安装把声明版本号的插件源码落到版本目录,并登记来源标签 local。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="1.0.0") + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + + success, message = manager.install_local_raw("DemoPlugin", repo_url="local://demoplugin") + + assert (success, message) == (True, "") + installed = plugin_root / "demoplugin" / "v1_0_0" / "__init__.py" + assert installed.is_file() + manifest = read_plugin_versions_manifest(plugin_root / "demoplugin") + assert manifest["current"] == "1.0.0" + assert manifest["versions"][0]["source"] == "local" + + +def test_release_install_lands_in_version_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """GitHub Release 制品安装把声明版本号的插件源码落到版本目录。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + release_tag = "DemoPlugin_v1.0.0" + zip_bytes = _zip_bytes( + {"__init__.py": "class DemoPlugin:\n plugin_version = '1.0.0'\n"} + ) + responses = iter( + [ + SimpleNamespace( + status_code=200, + json=lambda: {"assets": [{"name": f"{release_tag.lower()}.zip", "id": 42}]}, + ), + SimpleNamespace(status_code=200, content=zip_bytes), + ] + ) + monkeypatch.setattr( + manager, + "_PluginPackageManager__request_with_fallback", + lambda *_args, **_kwargs: next(responses), + ) + + ok, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", + False, + lambda staging_dir: manager._PluginPackageManager__install_from_release( + "DemoPlugin", "owner/repo", release_tag, staging_dir + ), + ) + + assert (ok, message) == (True, "") + installed = plugin_root / "demoplugin" / "v1_0_0" / "__init__.py" + assert installed.is_file() + manifest = read_plugin_versions_manifest(plugin_root / "demoplugin") + assert manifest["current"] == "1.0.0" + assert manifest["versions"][0]["source"] == "market" + + +def test_filelist_install_lands_in_version_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """市场文件列表安装把声明版本号的插件源码落到版本目录。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + source_content = "class DemoPlugin:\n plugin_version = '1.0.0'\n" + monkeypatch.setattr( + manager, + "_PluginPackageManager__get_file_list", + lambda *_args: ( + [{"path": "plugins/demoplugin/__init__.py", "download_url": "https://example.invalid/init"}], + "", + ), + ) + monkeypatch.setattr( + manager, + "_PluginPackageManager__request_with_fallback", + lambda *_args, **_kwargs: SimpleNamespace(status_code=200, text=source_content), + ) + + ok, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", + False, + lambda staging_dir: manager._PluginPackageManager__prepare_content_via_filelist_sync( + "DemoPlugin", "owner/repo", None, staging_dir + ), + ) + + assert (ok, message) == (True, "") + installed = plugin_root / "demoplugin" / "v1_0_0" / "__init__.py" + assert installed.is_file() + assert installed.read_text(encoding="utf-8") == source_content + manifest = read_plugin_versions_manifest(plugin_root / "demoplugin") + assert manifest["current"] == "1.0.0" + + +@pytest.mark.asyncio +async def test_async_filelist_install_lands_in_version_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """异步市场文件列表安装同样把内容落到版本目录,与同步路径行为一致。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + source_content = "class DemoPlugin:\n plugin_version = '1.0.0'\n" + + async def fake_get_file_list(*_args: object) -> tuple[list[dict[str, str]], str]: + """返回一个只含单个源文件的伪造市场目录列表。""" + return ( + [{"path": "plugins/demoplugin/__init__.py", "download_url": "https://example.invalid/init"}], + "", + ) + + async def fake_request(*_args: object, **_kwargs: object) -> SimpleNamespace: + """返回伪造的文件下载响应。""" + return SimpleNamespace(status_code=200, text=source_content) + + monkeypatch.setattr(manager, "_PluginPackageManager__async_get_file_list", fake_get_file_list) + monkeypatch.setattr(manager, "_PluginPackageManager__async_request_with_fallback", fake_request) + + async def prepare(staging_dir: Path) -> tuple[bool, str]: + """把市场文件列表内容准备进给定的暂存目录。""" + return await manager._PluginPackageManager__prepare_content_via_filelist_async( + "DemoPlugin", "owner/repo", None, staging_dir + ) + + ok, message = await manager._PluginPackageManager__install_flow_async( + "DemoPlugin", False, prepare, + ) + + assert (ok, message) == (True, "") + installed = plugin_root / "demoplugin" / "v1_0_0" / "__init__.py" + assert installed.is_file() + + +def test_install_without_declared_version_stays_flat_and_skips_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """读不到声明版本号时安装沿用平铺布局,不为其强行造版本目录或元信息。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version=None) + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + + success, message = manager.install_local_raw("DemoPlugin", repo_url="local://demoplugin") + + assert (success, message) == (True, "") + assert (plugin_root / "demoplugin" / "__init__.py").is_file() + assert not (plugin_root / "demoplugin" / "versions.json").exists() + assert plugin_version_dirs(plugin_root / "demoplugin") == {} + + +def test_same_version_local_reinstall_is_idempotent_and_stays_flat( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """已经是平铺布局时同版本重装保持平铺,不会为其凭空造出版本目录。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="1.0.0") + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + existing_dir = plugin_root / "demoplugin" + _write_flat_plugin(existing_dir, class_name="DemoPlugin", version="1.0.0") + + success, message = manager.install_local_raw("DemoPlugin", repo_url="local://demoplugin") + + assert (success, message) == (True, "") + assert (existing_dir / "__init__.py").is_file() + assert plugin_version_dirs(existing_dir) == {} + assert not (existing_dir / "versions.json").exists() + + +def test_first_multi_version_local_install_migrates_legacy_layout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """首次安装出现第二个版本号时,先把存量平铺源码迁移进版本目录再写入新版本。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="2.0.0") + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + existing_dir = plugin_root / "demoplugin" + _write_flat_plugin(existing_dir, class_name="DemoPlugin", version="1.0.0") + + success, message = manager.install_local_raw("DemoPlugin", repo_url="local://demoplugin") + + assert (success, message) == (True, "") + assert (existing_dir / "v1_0_0" / "__init__.py").is_file() + assert (existing_dir / "v2_0_0" / "__init__.py").is_file() + assert not (existing_dir / "__init__.py").exists() + manifest = read_plugin_versions_manifest(existing_dir) + assert manifest["current"] == "2.0.0" + versions_by_number = {entry["version"]: entry for entry in manifest["versions"]} + assert versions_by_number["1.0.0"]["source"] == "migrated" + assert versions_by_number["2.0.0"]["source"] == "local" + + +def test_multi_version_local_install_blocked_keeps_old_version_loadable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """写法体检命中阻断时拒绝安装,存量已装版本原样保留、不残留任何新目录。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="2.0.0") + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + existing_dir = plugin_root / "demoplugin" + existing_dir.mkdir(parents=True) + (existing_dir / "__init__.py").write_text( + "from app.plugins.demoplugin.utils import helper\n" + "class DemoPlugin:\n plugin_version = '1.0.0'\n", + encoding="utf-8", + ) + (existing_dir / "utils.py").write_text("def helper():\n pass\n", encoding="utf-8") + + success, message = manager.install_local_raw("DemoPlugin", repo_url="local://demoplugin") + + assert success is False + assert "自引用" in message + assert (existing_dir / "__init__.py").read_text(encoding="utf-8").startswith( + "from app.plugins.demoplugin.utils import helper" + ) + assert (existing_dir / "utils.py").is_file() + assert not (existing_dir / "v1_0_0").exists() + assert not (existing_dir / "v2_0_0").exists() + assert not (existing_dir / "versions.json").exists() + + +def test_install_rolls_back_when_dependency_install_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """依赖安装失败时插件根目录必须回滚到安装前内容,不留半份新版本。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + _write_flat_plugin(existing_dir, class_name="DemoPlugin", version="1.0.0") + (existing_dir / "marker.py").write_text("MARK = 1\n", encoding="utf-8") + + def failing_dependencies(*_args: object, **_kwargs: object) -> tuple[bool, bool, str]: + """模拟依赖安装失败。""" + return True, False, "依赖安装失败:模拟" + + monkeypatch.setattr( + manager, + "_PluginPackageManager__install_dependencies_if_required", + failing_dependencies, + ) + + def prepare_same_version(staging_dir: Path) -> tuple[bool, str]: + """准备一份内容不同但版本号相同的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="1.0.0") + (staging_dir / "marker.py").write_text("MARK = 2\n", encoding="utf-8") + return True, "" + + ok, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", False, prepare_same_version, + ) + + assert ok is False + assert message == "依赖安装失败:模拟" + assert (existing_dir / "marker.py").read_text(encoding="utf-8") == "MARK = 1\n" + assert (existing_dir / "__init__.py").is_file() + + +def test_install_rolls_back_when_target_resolver_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """目标目录解析端口失败时插件根目录必须回滚到安装前内容。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + _write_flat_plugin(existing_dir, class_name="DemoPlugin", version="1.0.0") + (existing_dir / "marker.py").write_text("MARK = 1\n", encoding="utf-8") + + def failing_resolver(*_args: object, **_kwargs: object) -> None: + """模拟版本目录解析端口异常。""" + raise RuntimeError("模拟解析失败") + + monkeypatch.setattr(manager, "_install_target_resolver", failing_resolver) + + def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份最小可用的替换内容。""" + staging_dir.mkdir(parents=True, exist_ok=True) + (staging_dir / "__init__.py").write_text("class DemoPlugin:\n pass\n", encoding="utf-8") + return True, "" + + ok, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", False, prepare, + ) + + assert ok is False + assert "解析插件安装目标失败" in message + assert (existing_dir / "marker.py").read_text(encoding="utf-8") == "MARK = 1\n" + assert (existing_dir / "__init__.py").is_file() + + +def test_reinstalling_an_existing_version_directory_overwrites_it_idempotently( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """重装已存在的版本目录直接覆盖该目录内容,元信息里的版本条目不重复。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_dir = existing_dir / "v1_0_0" + _write_flat_plugin(version_dir, class_name="DemoPlugin", version="1.0.0") + (version_dir / "marker.py").write_text("MARK = 1\n", encoding="utf-8") + register_plugin_version(existing_dir, "1.0.0", source="local") + + def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备同一版本号但内容不同的重装内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="1.0.0") + (staging_dir / "marker.py").write_text("MARK = 2\n", encoding="utf-8") + return True, "" + + ok, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", False, prepare, source_label="local", + ) + + assert (ok, message) == (True, "") + assert (version_dir / "marker.py").read_text(encoding="utf-8") == "MARK = 2\n" + manifest = read_plugin_versions_manifest(existing_dir) + assert manifest["current"] == "1.0.0" + assert len(manifest["versions"]) == 1 + + +# 六、安装失败清理收敛到单个版本,不牵连插件的其它已装版本 + + +def _register_real_version(plugin_root: Path, version: str, *, marker: str) -> Path: + """在插件根目录下就地写出一个真实版本目录并登记进版本元信息,供回滚测试模拟已装版本。 + + :param plugin_root: 插件源码根目录 + :param version: 版本号 + :param marker: 写入版本目录内 marker.py 的内容,用于断言该版本未被误删 + :return: 已写入的版本目录 + """ + version_dir = plugin_root / plugin_version_dir_name(version) + _write_flat_plugin(version_dir, class_name="DemoPlugin", version=version) + (version_dir / "marker.py").write_text(marker, encoding="utf-8") + register_plugin_version(plugin_root, version, source="local") + return version_dir + + +def test_remove_plugin_installed_version_keeps_sibling_versions_and_restores_previous_current( + tmp_path: Path, +) -> None: + """回滚失败版本只删该版本目录与元信息条目,当前版本精确复原为调用方传入的登记前的值。 + + ``previous_current`` 传入的是 1.0.0,既不是剩余版本里语义号最高的 + 2.0.0,也不是被删版本 3.0.0 之前链式注册产生的值,用来证明复原结果 + 只取决于调用方传入的值本身,不是按剩余版本重新猜一个。 + """ + plugin_root = tmp_path / "demoplugin" + version_a = _register_real_version(plugin_root, "1.0.0", marker="A") + version_b = _register_real_version(plugin_root, "2.0.0", marker="B") + version_c = _register_real_version(plugin_root, "3.0.0", marker="C") + assert read_plugin_versions_manifest(plugin_root)["current"] == "3.0.0" + + remove_plugin_installed_version(plugin_root, "3.0.0", "1.0.0") + + assert not version_c.exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + manifest = read_plugin_versions_manifest(plugin_root) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "2.0.0"} + assert manifest["current"] == "1.0.0" + + +def test_remove_plugin_installed_version_falls_back_to_empty_when_previous_current_is_gone( + tmp_path: Path, +) -> None: + """登记前的当前版本在回滚时已不在剩余版本清单中(理论上不该发生)时,置空而不是乱猜。""" + plugin_root = tmp_path / "demoplugin" + version_a = _register_real_version(plugin_root, "1.0.0", marker="A") + version_c = _register_real_version(plugin_root, "3.0.0", marker="C") + + remove_plugin_installed_version(plugin_root, "3.0.0", "9.9.9") + + assert not version_c.exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + manifest = read_plugin_versions_manifest(plugin_root) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0"} + assert manifest["current"] is None + + +def test_remove_plugin_installed_version_deletes_empty_plugin_root_after_only_version( + tmp_path: Path, +) -> None: + """被删版本是该插件唯一版本时,清理干净不留没有可用版本的空壳目录。""" + plugin_root = tmp_path / "demoplugin" + _register_real_version(plugin_root, "1.0.0", marker="A") + + remove_plugin_installed_version(plugin_root, "1.0.0", None) + + assert not plugin_root.exists() + + +def test_remove_plugin_installed_version_is_a_no_op_when_the_version_was_never_placed( + tmp_path: Path, +) -> None: + """要回滚的版本目录本就不存在(换入已回滚)时不报错,也不影响其它已装版本。""" + plugin_root = tmp_path / "demoplugin" + version_a = _register_real_version(plugin_root, "1.0.0", marker="A") + + remove_plugin_installed_version(plugin_root, "9.9.9", "1.0.0") + + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + manifest = read_plugin_versions_manifest(plugin_root) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0"} + assert manifest["current"] == "1.0.0" + + +def test_sync_install_failure_without_backup_only_removes_the_new_version( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """同步安装第三个版本时依赖安装失败且跳过备份,清理只收敛到第三个版本,另两个版本及登记完好。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + + def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明第三个版本号的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="3.0.0") + return True, "" + + monkeypatch.setattr( + manager, + "_PluginPackageManager__install_dependencies_if_required", + lambda *_args, **_kwargs: (True, False, "依赖安装失败:模拟"), + ) + + success, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert message == "依赖安装失败:模拟" + assert not (existing_dir / "v3_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + manifest = read_plugin_versions_manifest(existing_dir) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "2.0.0"} + assert manifest["current"] == "2.0.0" + + +@pytest.mark.asyncio +async def test_async_install_failure_without_backup_only_removes_the_new_version( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """异步安装路径的失败清理必须与同步路径一样只收敛到本次安装的新版本。 + + 本地来源的异步安装入口把整个同步流程原样丢进线程池执行,不会真正走到 + ``__install_flow_async``;直接驱动该私有方法才能实际覆盖异步流程自身 + 的清理分支,与既有的 ``test_async_filelist_install_lands_in_version_directory`` + 等测试同一手法。 + """ + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + + async def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明第三个版本号的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="3.0.0") + return True, "" + + async def failing_dependencies(*_args: object, **_kwargs: object) -> tuple[bool, bool, str]: + """模拟异步依赖安装失败。""" + return True, False, "依赖安装失败:模拟" + + monkeypatch.setattr( + manager, + "_PluginPackageManager__async_install_dependencies_if_required", + failing_dependencies, + ) + + success, message = await manager._PluginPackageManager__install_flow_async( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert message == "依赖安装失败:模拟" + assert not (existing_dir / "v3_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + manifest = read_plugin_versions_manifest(existing_dir) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "2.0.0"} + assert manifest["current"] == "2.0.0" + + +def test_sync_install_failure_restores_current_to_the_version_installed_before_this_attempt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """安装前 current 不是剩余版本里语义号最高者时,回滚同样精确复原为安装前的真实值,不回退到最高版本。 + + 版本 B(2.0.0)先于版本 A(1.0.0)注册,注册顺序决定安装第三个版本前 + current 是语义号较低的 1.0.0;旧实现按剩余版本语义号最高者回退会落到 + 2.0.0,掩盖这条缺陷。 + """ + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + assert read_plugin_versions_manifest(existing_dir)["current"] == "1.0.0" + + def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明第三个版本号的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="3.0.0") + return True, "" + + monkeypatch.setattr( + manager, + "_PluginPackageManager__install_dependencies_if_required", + lambda *_args, **_kwargs: (True, False, "依赖安装失败:模拟"), + ) + + success, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert message == "依赖安装失败:模拟" + assert not (existing_dir / "v3_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + manifest = read_plugin_versions_manifest(existing_dir) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "2.0.0"} + assert manifest["current"] == "1.0.0" + + +@pytest.mark.asyncio +async def test_async_install_failure_restores_current_that_is_neither_the_highest_nor_the_lowest_version( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """安装前 current 是三个已装版本里居中的一个时,回滚同样精确复原,既不落到最高也不落到最低。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_d = _register_real_version(existing_dir, "4.0.0", marker="D") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + assert read_plugin_versions_manifest(existing_dir)["current"] == "2.0.0" + + async def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明第四个版本号的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="5.0.0") + return True, "" + + async def failing_dependencies(*_args: object, **_kwargs: object) -> tuple[bool, bool, str]: + """模拟异步依赖安装失败。""" + return True, False, "依赖安装失败:模拟" + + monkeypatch.setattr( + manager, + "_PluginPackageManager__async_install_dependencies_if_required", + failing_dependencies, + ) + + success, message = await manager._PluginPackageManager__install_flow_async( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert message == "依赖安装失败:模拟" + assert not (existing_dir / "v5_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + assert (version_d / "marker.py").read_text(encoding="utf-8") == "D" + manifest = read_plugin_versions_manifest(existing_dir) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "2.0.0", "4.0.0"} + assert manifest["current"] == "2.0.0" + + +def test_sync_install_failure_for_the_first_ever_version_leaves_no_empty_shell( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """插件在安装这一次之前没有任何已装版本、首个版本安装失败时,回滚后不留没有可用版本的空壳目录。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + assert not existing_dir.exists() + + def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明首个版本号的插件内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="1.0.0") + return True, "" + + monkeypatch.setattr( + manager, + "_PluginPackageManager__install_dependencies_if_required", + lambda *_args, **_kwargs: (True, False, "依赖安装失败:模拟"), + ) + + success, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert message == "依赖安装失败:模拟" + assert not existing_dir.exists() + + +def test_sync_install_failure_without_backup_in_flat_layout_still_removes_whole_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """平铺布局(无版本目录)下失败清理行为保持与改动前一致:整根插件目录被清理。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="1.0.0") + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + existing_dir = plugin_root / "demoplugin" + _write_flat_plugin(existing_dir, class_name="DemoPlugin", version="1.0.0") + (existing_dir / "marker.py").write_text("MARK = 1\n", encoding="utf-8") + + monkeypatch.setattr( + manager, + "_PluginPackageManager__install_dependencies_if_required", + lambda *_args, **_kwargs: (True, False, "依赖安装失败:模拟"), + ) + + success, message = manager.install_local_raw( + "DemoPlugin", repo_url="local://demoplugin", force_install=True, + ) + + assert success is False + assert message == "依赖安装失败:模拟" + assert not existing_dir.exists() + + +# 七、换入未提交与换入已提交后失败的清理边界 + + +def _fail_rename_from_directory(root: Path): + """构造只让位于给定目录下的源路径改名失败(模拟 EXDEV)的 os.rename 替身,其余改名走真实实现。""" + real_rename = os.rename + + def fake_rename(src: object, dst: object) -> None: + """按源路径是否位于给定目录内决定是否伪造跨设备改名失败。""" + if Path(str(src)).is_relative_to(root): + raise OSError(errno.EXDEV, "Invalid cross-device link") + real_rename(src, dst) + + return fake_rename + + +def _fail_copytree_into_directory(root: Path, marker_name: str, message: str): + """构造只让目标路径位于给定目录树内的 copytree 调用失败的替身,先落半份新内容再抛错。""" + real_copytree = shutil.copytree + + def fake_copytree(src: object, dst: object, **kwargs: object) -> None: + """按目标路径是否位于给定目录内决定是否伪造复制中途磁盘写满。""" + if Path(str(dst)).is_relative_to(root): + Path(str(dst)).mkdir(parents=True, exist_ok=True) + (Path(str(dst)) / marker_name).write_text("partial", encoding="utf-8") + raise OSError(errno.ENOSPC, message) + real_copytree(src, dst, **kwargs) + + return fake_copytree + + +def _patch_swap_to_fail_writing_into( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, plugin_root: Path, +) -> None: + """让暂存目录改名失败并让退化复制在写入插件根目录时中途失败,复现换入失败场景。""" + monkeypatch.setattr( + plugin_package_module.os, + "rename", + _fail_rename_from_directory(tmp_path / "temp" / "plugin_install_staging"), + ) + monkeypatch.setattr( + plugin_package_module.shutil, + "copytree", + _fail_copytree_into_directory(plugin_root, "partial.txt", "No space left on device"), + ) + + +def test_sync_install_swap_failure_in_flat_layout_leaves_directory_untouched( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """平铺布局强制安装换入失败时插件目录必须逐字节完好,不被失败清理二次删除。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version=None) + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + existing_dir = plugin_root / "demoplugin" + _write_flat_plugin(existing_dir, class_name="DemoPlugin", version=None) + (existing_dir / "marker.py").write_text("MARK = 1\n", encoding="utf-8") + init_before = (existing_dir / "__init__.py").read_text(encoding="utf-8") + marker_before = (existing_dir / "marker.py").read_text(encoding="utf-8") + + _patch_swap_to_fail_writing_into(monkeypatch, tmp_path, plugin_root) + + success, message = manager.install_local_raw( + "DemoPlugin", repo_url="local://demoplugin", force_install=True, + ) + + assert success is False + assert "写入插件内容失败" in message + assert existing_dir.is_dir() + assert (existing_dir / "__init__.py").read_text(encoding="utf-8") == init_before + assert (existing_dir / "marker.py").read_text(encoding="utf-8") == marker_before + assert not (existing_dir / "partial.txt").exists() + + +def test_sync_install_swap_failure_installing_new_version_leaves_existing_versions_untouched( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """版本化布局强制安装新版本换入失败时,已装版本、清单与目标版本目录都回到安装前状态。""" + source_dir = tmp_path / "repo" / "demoplugin" + _write_flat_plugin(source_dir, class_name="DemoPlugin", version="3.0.0") + manager, plugin_root = _versioned_manager( + monkeypatch, tmp_path, source=_local_source_port(source_dir) + ) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + manifest_before = read_plugin_versions_manifest(existing_dir) + + _patch_swap_to_fail_writing_into(monkeypatch, tmp_path, plugin_root) + + success, message = manager.install_local_raw( + "DemoPlugin", repo_url="local://demoplugin", force_install=True, + ) + + assert success is False + assert "写入插件内容失败" in message + assert not (existing_dir / "v3_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + assert read_plugin_versions_manifest(existing_dir) == manifest_before + + +@pytest.mark.asyncio +async def test_async_install_swap_failure_installing_new_version_leaves_existing_versions_untouched( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """异步安装路径下版本化布局换入失败同样不得清理已恢复的目录,与同步路径行为一致。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + manifest_before = read_plugin_versions_manifest(existing_dir) + + async def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明第三个版本号的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="3.0.0") + return True, "" + + _patch_swap_to_fail_writing_into(monkeypatch, tmp_path, plugin_root) + + success, message = await manager._PluginPackageManager__install_flow_async( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert "写入插件内容失败" in message + assert not (existing_dir / "v3_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + assert read_plugin_versions_manifest(existing_dir) == manifest_before + + +def test_sync_install_swap_failure_reinstalling_existing_version_restores_original_content( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """重装已存在版本目录时换入失败,该版本必须恢复为换入前内容,不被失败清理连版本一起删除。 + + 这个场景才是新缺陷的真实复现:目标版本目录换入前已经存在真实内容, + 换入函数自身已经把它换回;旧的清理路径不区分“换入未提交”和“换入已 + 提交后失败”,会把刚恢复好的这份内容连同版本登记一起删掉。 + """ + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + marker_before = (version_b / "marker.py").read_text(encoding="utf-8") + manifest_before = read_plugin_versions_manifest(existing_dir) + + def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备同一版本号但内容不同的重装内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="2.0.0") + (staging_dir / "marker.py").write_text("MARK = 2-new\n", encoding="utf-8") + return True, "" + + _patch_swap_to_fail_writing_into(monkeypatch, tmp_path, plugin_root) + + success, message = manager._PluginPackageManager__install_flow_sync( + "DemoPlugin", True, prepare, source_label="local", + ) + + assert success is False + assert "写入插件内容失败" in message + assert version_b.is_dir() + assert (version_b / "marker.py").read_text(encoding="utf-8") == marker_before + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert read_plugin_versions_manifest(existing_dir) == manifest_before + + +@pytest.mark.asyncio +async def test_async_install_cleans_up_new_version_when_registration_fails_after_successful_swap( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """换入已经成功提交、版本元信息登记随后失败时,仍按既有语义清理本次安装写入的版本目录。""" + manager, plugin_root = _versioned_manager(monkeypatch, tmp_path) + existing_dir = plugin_root / "demoplugin" + version_a = _register_real_version(existing_dir, "1.0.0", marker="A") + version_b = _register_real_version(existing_dir, "2.0.0", marker="B") + manifest_before = read_plugin_versions_manifest(existing_dir) + + async def prepare(staging_dir: Path) -> tuple[bool, str]: + """准备一份声明第三个版本号的替换内容。""" + _write_flat_plugin(staging_dir, class_name="DemoPlugin", version="3.0.0") + return True, "" + + def failing_registrar(*_args: object, **_kwargs: object) -> None: + """模拟版本元信息登记端口异常。""" + raise RuntimeError("模拟登记失败") + + monkeypatch.setattr(manager, "_install_version_registrar", failing_registrar) + + success, message = await manager._PluginPackageManager__install_flow_async( + "DemoPlugin", True, prepare, + ) + + assert success is False + assert "登记插件版本元信息失败" in message + assert not (existing_dir / "v3_0_0").exists() + assert (version_a / "marker.py").read_text(encoding="utf-8") == "A" + assert (version_b / "marker.py").read_text(encoding="utf-8") == "B" + assert read_plugin_versions_manifest(existing_dir) == manifest_before diff --git a/tests/test_plugin_version_layout.py b/tests/test_plugin_version_layout.py new file mode 100644 index 0000000000..b7f5ecdfa0 --- /dev/null +++ b/tests/test_plugin_version_layout.py @@ -0,0 +1,511 @@ +"""插件源码按版本分目录布局的目录名映射、元信息读写与加载路径解析测试。""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.version import ( + PLUGIN_VERSIONS_MANIFEST_NAME, + ensure_plugin_version_dir_available, + plugin_manifest_versions, + plugin_version_dir_name, + plugin_version_dirs, + plugin_version_from_dir_name, + read_declared_plugin_version, + read_plugin_versions_manifest, + resolve_instance_version_dir, + resolve_plugin_version_dir, + write_plugin_versions_manifest, +) +from app.schemas.plugin import PluginInstance + + +def _logger() -> SimpleNamespace: + """提供加载器测试所需的最小日志端口。""" + return SimpleNamespace( + debug=lambda *_args: None, + info=lambda *_args: None, + warning=lambda *_args: None, + error=lambda *_args: None, + ) + + +def _make_loader(plugins_root: Path) -> PluginLoader: + """构造只需最小日志端口的加载器实例。""" + return PluginLoader( + plugins_root=plugins_root, + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + log=_logger(), + ) + + +def _write_version( + plugins_root: Path, + plugin_id: str, + version: str, + *, + class_name: str, + declared_version: str | None = None, +) -> Path: + """在插件根目录下写入一个版本目录的最小可加载源码。 + + :param plugins_root: 插件根目录 + :param plugin_id: 插件目录名 + :param version: 版本号,决定版本目录名 + :param class_name: 插件主类名 + :param declared_version: 类体内声明的 plugin_version 值,默认与 version 相同 + :return: 版本目录 + """ + plugin_root = plugins_root / plugin_id + version_dir = plugin_root / plugin_version_dir_name(version) + version_dir.mkdir(parents=True) + (version_dir / "__init__.py").write_text( + f"class {class_name}:\n" + f" plugin_version = {declared_version or version!r}\n" + " def init_plugin(self, config=None):\n" + " pass\n", + encoding="utf-8", + ) + return version_dir + + +def _write_manifest( + plugin_root: Path, + entries: list[tuple[str, str]], + current: str | None, +) -> None: + """写入版本元信息文件。 + + :param plugin_root: 插件源码根目录 + :param entries: (版本号, 目录名) 列表 + :param current: 当前生效版本号 + """ + versions = [ + { + "version": version, + "directory": directory, + "installed_at": "2026-01-01T00:00:00+00:00", + "source": "test", + } + for version, directory in entries + ] + write_plugin_versions_manifest(plugin_root, versions, current) + + +@pytest.fixture(autouse=True) +def _isolate_plugin_modules(): + """回收测试期间手动导入的临时插件模块,避免污染其它用例的模块缓存。""" + before = set(sys.modules) + yield + for name in set(sys.modules) - before: + if name.startswith("app.plugins."): + sys.modules.pop(name, None) + + +# 一、版本号与目录名的映射 + + +@pytest.mark.parametrize( + "version, dir_name", + [ + ("1.2.0", "v1_2_0"), + ("2.0", "v2_0"), + ("1.2.0-beta.1", "v1_2_0-beta_1"), + ("1.0.0+build.5", "v1_0_0+build_5"), + ("10.20.30-rc.1+exp.sha.5114f85", "v10_20_30-rc_1+exp_sha_5114f85"), + ], +) +def test_version_dir_name_mapping_is_reversible(version: str, dir_name: str) -> None: + """版本号到目录名的映射可逆,反解结果与原版本号一致。""" + assert plugin_version_dir_name(version) == dir_name + assert plugin_version_from_dir_name(dir_name) == version + + +@pytest.mark.parametrize( + "dir_name", + ["dist", "wheels", "__pycache__", "v", "v1.2.0", "1_2_0", ""], +) +def test_non_version_directory_names_are_not_reversed(dir_name: str) -> None: + """非版本目录名不会被误解析为版本号。""" + assert plugin_version_from_dir_name(dir_name) is None + + +@pytest.mark.parametrize("version", ["1_2_0", "1.2 .0", "1.2.0/x", "../x", "", " "]) +def test_non_semantic_version_numbers_are_rejected(version: str) -> None: + """非语义化版本号被拒绝,不做静默转换。""" + with pytest.raises(ValueError): + plugin_version_dir_name(version) + + +def test_case_insensitive_directory_collision_is_rejected(tmp_path: Path) -> None: + """同插件两个版本的目录名仅大小写不同时拒绝安装。""" + _write_version(tmp_path, "casing", "1.0.0-Beta", class_name="CasingPlugin") + plugin_root = tmp_path / "casing" + + assert ensure_plugin_version_dir_available(plugin_root, "2.0.0") == "v2_0_0" + with pytest.raises(ValueError): + ensure_plugin_version_dir_available(plugin_root, "1.0.0-beta") + + +def test_plugin_version_dirs_lists_only_version_directories(tmp_path: Path) -> None: + """扫描结果只包含能反解为版本号的目录,忽略杂项条目。""" + plugin_root = tmp_path / "scanned" + (plugin_root / "v1_0_0").mkdir(parents=True) + (plugin_root / "dist").mkdir() + (plugin_root / PLUGIN_VERSIONS_MANIFEST_NAME).write_text("{}", encoding="utf-8") + + assert set(plugin_version_dirs(plugin_root)) == {"1.0.0"} + + +def test_plugin_version_dirs_is_empty_when_root_is_missing(tmp_path: Path) -> None: + """插件从未安装时,插件根目录不存在,扫描结果为空字典。""" + assert plugin_version_dirs(tmp_path / "never_installed") == {} + + +# 二、多版本并存与解析 + + +def test_two_versions_coexist_and_resolve_independently_by_version( + tmp_path: Path, +) -> None: + """两个版本目录并存时,指定版本号各自解析到对应的版本目录。""" + _write_version(tmp_path, "dual", "1.2.0", class_name="DualPlugin") + _write_version(tmp_path, "dual", "2.0.0", class_name="DualPlugin") + plugin_root = tmp_path / "dual" + + old_dir = resolve_plugin_version_dir(plugin_root, version="1.2.0") + new_dir = resolve_plugin_version_dir(plugin_root, version="2.0.0") + + assert old_dir.name == "v1_2_0" + assert new_dir.name == "v2_0_0" + + +def test_resolving_an_uninstalled_version_raises(tmp_path: Path) -> None: + """请求一个磁盘上不存在的版本时报错,不静默换成其它版本。""" + _write_version(tmp_path, "partial", "1.0.0", class_name="PartialPlugin") + plugin_root = tmp_path / "partial" + + with pytest.raises(ValueError): + resolve_plugin_version_dir(plugin_root, version="9.9.9") + + +def test_current_version_comes_from_the_manifest_not_the_highest( + tmp_path: Path, +) -> None: + """不指定版本时按元信息登记的当前版本加载,而不是版本号最高的。""" + _write_version(tmp_path, "pinned", "1.2.0", class_name="PinnedPlugin") + _write_version(tmp_path, "pinned", "2.0.0", class_name="PinnedPlugin") + plugin_root = tmp_path / "pinned" + _write_manifest( + plugin_root, [("1.2.0", "v1_2_0"), ("2.0.0", "v2_0_0")], current="1.2.0" + ) + + assert resolve_plugin_version_dir(plugin_root).name == "v1_2_0" + + +def test_missing_manifest_falls_back_to_the_highest_installed_version( + tmp_path: Path, +) -> None: + """元信息文件缺失时回落到磁盘上版本号最高的版本目录。""" + _write_version(tmp_path, "fallback", "1.2.0", class_name="FallbackPlugin") + _write_version(tmp_path, "fallback", "10.0.0", class_name="FallbackPlugin") + + assert resolve_plugin_version_dir(tmp_path / "fallback").name == "v10_0_0" + + +def test_manifest_current_missing_on_disk_falls_back_to_the_highest_version( + tmp_path: Path, +) -> None: + """元信息登记的当前版本在磁盘上已不存在时,回落到版本号最高的已装版本。""" + _write_version(tmp_path, "stale", "1.2.0", class_name="StalePlugin") + _write_version(tmp_path, "stale", "2.0.0", class_name="StalePlugin") + plugin_root = tmp_path / "stale" + _write_manifest(plugin_root, [("5.0.0", "v5_0_0")], current="5.0.0") + + assert resolve_plugin_version_dir(plugin_root).name == "v2_0_0" + + +def test_manifest_directory_mismatch_prefers_the_manifest_version( + tmp_path: Path, +) -> None: + """目录名不是权威真值,与元信息版本号不一致时以元信息推出的目录名为准。""" + _write_version(tmp_path, "drift", "1.2.0", class_name="DriftPlugin") + plugin_root = tmp_path / "drift" + _write_manifest(plugin_root, [("1.2.0", "v9_9_9")], current="1.2.0") + + assert plugin_manifest_versions(plugin_root) == {"1.2.0": "v1_2_0"} + assert resolve_plugin_version_dir(plugin_root).name == "v1_2_0" + + +def test_plugin_manifest_versions_ignores_entries_with_invalid_version( + tmp_path: Path, +) -> None: + """元信息中版本号非法或缺失的条目被忽略,不参与目录名推导。""" + plugin_root = tmp_path / "malformed" + write_plugin_versions_manifest( + plugin_root, + [ + {"version": "1_2_0", "directory": "v1_2_0"}, + {"directory": "v2_0_0"}, + {"version": "2.0.0", "directory": "v2_0_0"}, + ], + current="2.0.0", + ) + + assert plugin_manifest_versions(plugin_root) == {"2.0.0": "v2_0_0"} + + +def test_resolve_falls_back_to_the_plugin_root_without_any_version_directory( + tmp_path: Path, +) -> None: + """插件根目录下没有任何版本目录时按平铺布局回落到插件根目录本身。 + + 这是今天所有插件的现状:没有安装任何版本目录,加载行为必须与引入版本化 + 布局之前逐字一致,不指定版本和指定版本都回落到同一个目录。 + """ + plugin_root = tmp_path / "flat" + plugin_root.mkdir(parents=True) + (plugin_root / "__init__.py").write_text( + "class FlatPlugin:\n plugin_version = '1.0.0'\n", encoding="utf-8" + ) + + assert resolve_plugin_version_dir(plugin_root) == plugin_root + assert resolve_plugin_version_dir(plugin_root, version="1.0.0") == plugin_root + + +# 三、加载器接线 + + +def test_loader_imports_the_manifest_current_version(tmp_path: Path) -> None: + """加载器在存在多个版本目录时按元信息登记的当前版本导入插件类。""" + _write_version(tmp_path, "loaded", "1.0.0", class_name="LoadedPlugin") + _write_version(tmp_path, "loaded", "3.1.0", class_name="LoadedPlugin") + plugin_root = tmp_path / "loaded" + _write_manifest( + plugin_root, [("1.0.0", "v1_0_0"), ("3.1.0", "v3_1_0")], current="3.1.0" + ) + + plugins = _make_loader(tmp_path).load( + None, ["Loaded"], lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert [plugin.__name__ for plugin in plugins] == ["LoadedPlugin"] + assert plugins[0].plugin_version == "3.1.0" + + +def test_loader_still_imports_a_flat_layout_plugin_without_version_directories( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """没有版本目录的存量插件仍按插件根目录直接导入,行为不受版本化布局影响。 + + 平铺布局下 ``resolve_plugin_version_dir`` 回落到插件根目录本身,加载器随即 + 沿用标准包导入机制,因此需要把临时插件根目录并入 ``app.plugins`` 的命名空间 + 包搜索路径,才能让 ``importlib.import_module`` 找到它。 + """ + plugins_package = importlib.import_module("app.plugins") + monkeypatch.setattr( + plugins_package, "__path__", [*plugins_package.__path__, str(tmp_path)] + ) + plugin_root = tmp_path / "legacy" + plugin_root.mkdir(parents=True) + (plugin_root / "__init__.py").write_text( + "class LegacyPlugin:\n" + " plugin_version = '1.0.0'\n" + " def init_plugin(self, config=None):\n" + " pass\n", + encoding="utf-8", + ) + + plugins = _make_loader(tmp_path).load( + None, ["Legacy"], lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert [plugin.__name__ for plugin in plugins] == ["LegacyPlugin"] + + +def test_load_instance_uses_the_resolved_current_version_directory( + tmp_path: Path, +) -> None: + """虚拟实例加载沿用版本解析结果,从当前版本目录取源码而不是插件根目录。""" + _write_version(tmp_path, "versioned", "1.0.0", class_name="VersionedPlugin") + _write_version(tmp_path, "versioned", "2.0.0", class_name="VersionedPlugin") + plugin_root = tmp_path / "versioned" + _write_manifest( + plugin_root, [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="2.0.0" + ) + + instance = PluginInstance(instance_id="VersionedWork", source_plugin_id="Versioned") + plugins = _make_loader(tmp_path).load_instance( + instance, lambda candidate: hasattr(candidate, "init_plugin") + ) + + assert len(plugins) == 1 + assert plugins[0].__name__ == "VersionedWork" + assert plugins[0].plugin_version == "2.0.0" + + +def test_import_versioned_module_clears_module_cache_when_exec_fails( + tmp_path: Path, +) -> None: + """按版本目录导入模块执行失败时清除半成品缓存,不让后续加载命中坏对象。 + + 标准 importlib 在 ``exec_module`` 抛错时会移除已经预置的 ``sys.modules`` 键; + 按版本目录手动构造模块规格的分支必须复现同一行为,否则失败后的模块对象 + 会残留在缓存里,后续加载直接拿到这个半成品。 + """ + plugin_root = tmp_path / "broken" + version_dir = plugin_root / "v1_0_0" + version_dir.mkdir(parents=True) + (version_dir / "__init__.py").write_text("raise RuntimeError('boom')\n", encoding="utf-8") + module_name = "app.plugins.broken" + + with pytest.raises(RuntimeError): + PluginLoader._import_versioned_module(module_name, version_dir) + + assert module_name not in sys.modules + + +# 四、元信息文件读写 + + +def test_write_and_read_plugin_versions_manifest_round_trip(tmp_path: Path) -> None: + """写入的版本元信息可以原样读回。""" + plugin_root = tmp_path / "roundtrip" + entries = [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")] + _write_manifest(plugin_root, entries, current="2.0.0") + + manifest = read_plugin_versions_manifest(plugin_root) + + assert manifest["current"] == "2.0.0" + assert manifest["plugin_id"] == "roundtrip" + assert [entry["version"] for entry in manifest["versions"]] == ["1.0.0", "2.0.0"] + + +def test_read_plugin_versions_manifest_returns_empty_when_missing( + tmp_path: Path, +) -> None: + """元信息文件不存在时返回空字典,不抛出异常。""" + assert read_plugin_versions_manifest(tmp_path / "absent") == {} + + +def test_read_plugin_versions_manifest_returns_empty_when_corrupt( + tmp_path: Path, +) -> None: + """元信息文件内容损坏时按未登记处理,返回空字典。""" + plugin_root = tmp_path / "corrupt" + plugin_root.mkdir(parents=True) + (plugin_root / PLUGIN_VERSIONS_MANIFEST_NAME).write_text( + "{not valid json", encoding="utf-8" + ) + + assert read_plugin_versions_manifest(plugin_root) == {} + + +# 五、声明版本号解析 + + +def test_read_declared_plugin_version_extracts_the_class_attribute( + tmp_path: Path, +) -> None: + """从插件主类的 plugin_version 类属性静态解析出声明版本号。""" + init_file = tmp_path / "__init__.py" + init_file.write_text( + "class SomePlugin:\n plugin_version = '1.3.0'\n", encoding="utf-8" + ) + + assert read_declared_plugin_version(init_file) == "1.3.0" + + +def test_read_declared_plugin_version_returns_none_when_absent( + tmp_path: Path, +) -> None: + """插件主类没有声明 plugin_version 时返回 None。""" + init_file = tmp_path / "__init__.py" + init_file.write_text("class SomePlugin:\n plugin_name = 'Some'\n", encoding="utf-8") + + assert read_declared_plugin_version(init_file) is None + + +def test_read_declared_plugin_version_returns_none_for_unparseable_source( + tmp_path: Path, +) -> None: + """源码存在语法错误或文件不存在时返回 None,不抛出异常。""" + assert read_declared_plugin_version(tmp_path / "missing" / "__init__.py") is None + + broken_file = tmp_path / "broken.py" + broken_file.write_text("class Broken(:\n", encoding="utf-8") + assert read_declared_plugin_version(broken_file) is None + + +# 六、实例版本绑定解析 + + +def test_resolve_instance_version_dir_uses_current_version_without_an_instance( + tmp_path: Path, +) -> None: + """没有实例(即源插件本身)时按插件当前版本解析。""" + _write_version(tmp_path, "solo", "1.0.0", class_name="SoloPlugin") + _write_version(tmp_path, "solo", "2.0.0", class_name="SoloPlugin") + plugin_root = tmp_path / "solo" + _write_manifest(plugin_root, [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="2.0.0") + + assert resolve_instance_version_dir(plugin_root, None).name == "v2_0_0" + + +def test_resolve_instance_version_dir_follows_current_version(tmp_path: Path) -> None: + """跟随当前版本的实例按插件当前版本解析,忽略自身曾经生效过的版本。""" + _write_version(tmp_path, "followed", "1.0.0", class_name="FollowedPlugin") + _write_version(tmp_path, "followed", "2.0.0", class_name="FollowedPlugin") + plugin_root = tmp_path / "followed" + _write_manifest(plugin_root, [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="2.0.0") + instance = PluginInstance( + instance_id="FollowedWork", + source_plugin_id="Followed", + plugin_version="1.0.0", + follow_current_version=True, + ) + + assert resolve_instance_version_dir(plugin_root, instance).name == "v2_0_0" + + +def test_resolve_instance_version_dir_uses_the_pinned_version_when_not_following( + tmp_path: Path, +) -> None: + """不跟随当前版本的实例按自身绑定的版本解析,即使不是插件当前版本。""" + _write_version(tmp_path, "pinned", "1.0.0", class_name="PinnedPlugin") + _write_version(tmp_path, "pinned", "2.0.0", class_name="PinnedPlugin") + plugin_root = tmp_path / "pinned" + _write_manifest(plugin_root, [("1.0.0", "v1_0_0"), ("2.0.0", "v2_0_0")], current="2.0.0") + instance = PluginInstance( + instance_id="PinnedWork", + source_plugin_id="Pinned", + plugin_version="1.0.0", + follow_current_version=False, + ) + + assert resolve_instance_version_dir(plugin_root, instance).name == "v1_0_0" + + +def test_resolve_instance_version_dir_falls_back_when_the_pinned_version_is_gone( + tmp_path: Path, +) -> None: + """绑定版本的目录已从磁盘移除时回落到当前版本,而不是让解析失败。""" + _write_version(tmp_path, "stale", "2.0.0", class_name="StalePlugin") + plugin_root = tmp_path / "stale" + _write_manifest(plugin_root, [("2.0.0", "v2_0_0")], current="2.0.0") + instance = PluginInstance( + instance_id="StaleWork", + source_plugin_id="Stale", + plugin_version="1.0.0", + follow_current_version=False, + ) + + assert resolve_instance_version_dir(plugin_root, instance).name == "v2_0_0" diff --git a/tests/test_plugin_version_readiness.py b/tests/test_plugin_version_readiness.py new file mode 100644 index 0000000000..eef1c87ed9 --- /dev/null +++ b/tests/test_plugin_version_readiness.py @@ -0,0 +1,427 @@ +"""插件多版本目录布局静态扫描的合同测试。""" + +from __future__ import annotations + +from pathlib import Path + +from app.runtime.compat.readiness import ( + plugin_multi_version_blockers, + scan_plugin_version_readiness, +) + + +def _write_plugin(root: Path, plugin_id: str, files: dict[str, str]) -> Path: + """按给定文件映射写入一个仅用于静态扫描的插件源码目录。""" + plugin_dir = root / plugin_id + plugin_dir.mkdir(parents=True) + for relative_path, content in files.items(): + target = plugin_dir / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return plugin_dir + + +def test_relative_imports_report_all_three_criteria_as_clean(tmp_path: Path) -> None: + """只用相对 import、不依赖其它插件、不继承共享 Base 的插件三项判据均为否。""" + plugin_dir = _write_plugin( + tmp_path, + "cleanplugin", + { + "__init__.py": "from .utils import helper\n\nhelper()\n", + "utils.py": "def helper():\n return 1\n", + }, + ) + + readiness = scan_plugin_version_readiness("cleanplugin", plugin_dir) + + assert readiness.is_clean + assert readiness.has_self_referential_imports is False + assert readiness.has_cross_plugin_imports is False + assert readiness.has_shared_base_models is False + assert readiness.unparsed_files == () + + +def test_self_referential_import_from_reports_file_line_and_suggestion( + tmp_path: Path, +) -> None: + """from app.plugins.<自身pid>.xxx import X 应精确报出文件、行号和相对写法建议。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": "\nfrom app.plugins.myplugin.utils import helper\n", + "utils.py": "def helper():\n pass\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports + assert len(readiness.self_referential_imports) == 1 + hit = readiness.self_referential_imports[0] + assert hit.file == "__init__.py" + assert hit.line == 2 + assert hit.statement == "from app.plugins.myplugin.utils import helper" + assert hit.suggestion == "from .utils import helper" + + +def test_self_referential_plain_import_reports_relative_module_suggestion( + tmp_path: Path, +) -> None: + """import app.plugins.<自身pid>.xxx 应报出改写为相对 from-import 的建议。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": "import app.plugins.myplugin.utils as utils_mod\n", + "utils.py": "value = 1\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports + hit = readiness.self_referential_imports[0] + assert hit.file == "__init__.py" + assert hit.line == 1 + assert hit.statement == "import app.plugins.myplugin.utils as utils_mod" + assert hit.suggestion == "from . import utils as utils_mod" + + +def test_self_referential_dynamic_import_module_reports_suggestion(tmp_path: Path) -> None: + """importlib.import_module("app.plugins.<自身pid>.xxx") 字符串常量形式应被识别。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "import importlib\n" + 'importlib.import_module("app.plugins.myplugin.utils")\n' + ), + "utils.py": "value = 1\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports + hit = readiness.self_referential_imports[0] + assert hit.file == "__init__.py" + assert hit.line == 2 + assert hit.statement == 'importlib.import_module("app.plugins.myplugin.utils")' + assert "from .utils import" in hit.suggestion + + +def test_self_referential_import_inside_type_checking_block_is_not_reported( + tmp_path: Path, +) -> None: + """if TYPE_CHECKING: 内的自引用绝对 import 运行期不执行,不应计入阻断。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "from typing import TYPE_CHECKING\n\n" + "if TYPE_CHECKING:\n" + " from app.plugins.myplugin.utils import helper\n" + ), + "utils.py": "def helper():\n pass\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports is False + + +def test_self_referential_import_inside_type_checking_else_branch_is_still_reported( + tmp_path: Path, +) -> None: + """if TYPE_CHECKING: 的 else 分支运行期正常执行,其中的自引用导入仍应计入阻断。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "from typing import TYPE_CHECKING\n\n" + "if TYPE_CHECKING:\n" + " pass\n" + "else:\n" + " from app.plugins.myplugin.utils import helper\n" + ), + "utils.py": "def helper():\n pass\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports + hit = readiness.self_referential_imports[0] + assert hit.statement == "from app.plugins.myplugin.utils import helper" + + +def test_cross_plugin_import_is_not_confused_with_self_reference(tmp_path: Path) -> None: + """引用其它插件应归入跨插件依赖类,不计入自引用绝对 import。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + {"__init__.py": "from app.plugins.otherplugin import Thing\n"}, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports is False + assert readiness.has_cross_plugin_imports + hit = readiness.cross_plugin_imports[0] + assert hit.file == "__init__.py" + assert hit.line == 1 + assert hit.target_plugin_id == "otherplugin" + assert hit.statement == "from app.plugins.otherplugin import Thing" + + +def test_cross_plugin_absolute_import_and_dynamic_import_are_both_classified( + tmp_path: Path, +) -> None: + """跨插件依赖的 import 与 importlib.import_module 形式都应归入跨插件类。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "import app.plugins.otherplugin.helpers\n" + "import importlib\n" + 'importlib.import_module("app.plugins.thirdplugin.tools")\n' + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_self_referential_imports is False + targets = {hit.target_plugin_id for hit in readiness.cross_plugin_imports} + assert targets == {"otherplugin", "thirdplugin"} + + +def test_shared_base_model_via_from_app_db_import_is_reported(tmp_path: Path) -> None: + """from app.db import Base 继承应被识别为共享基类建模。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "from app.db import Base\n" + "from sqlalchemy.orm import Mapped, mapped_column\n\n" + "class MyData(Base):\n" + " id: Mapped[int] = mapped_column(primary_key=True)\n" + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_shared_base_models + hit = readiness.shared_base_models[0] + assert hit.file == "__init__.py" + assert hit.class_name == "MyData" + assert hit.line == 4 + + +def test_shared_base_model_via_app_db_base_module_attribute_is_reported( + tmp_path: Path, +) -> None: + """import app.db.base 后以 app.db.base.Base 继承也应被识别。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "import app.db.base\n\n" + "class MyData(app.db.base.Base):\n" + " pass\n" + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_shared_base_models + assert readiness.shared_base_models[0].class_name == "MyData" + + +def test_shared_base_model_via_module_alias_is_reported(tmp_path: Path) -> None: + """import app.db as db 后以 db.Base 继承也应被识别。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "import app.db as db\n\n" + "class MyData(db.Base):\n" + " pass\n" + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_shared_base_models + assert readiness.shared_base_models[0].class_name == "MyData" + + +def test_shared_base_model_via_from_package_import_submodule_with_alias_is_reported( + tmp_path: Path, +) -> None: + """from app.db import base as db_base 后以 db_base.Base 继承应被识别。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "from app.db import base as db_base\n\n" + "class MyData(db_base.Base):\n" + " pass\n" + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_shared_base_models + assert readiness.shared_base_models[0].class_name == "MyData" + + +def test_shared_base_model_via_from_package_import_submodule_without_alias_is_reported( + tmp_path: Path, +) -> None: + """from app.db import base(无 as)后以 base.Base 继承应被识别。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "from app.db import base\n\n" + "class MyData(base.Base):\n" + " pass\n" + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_shared_base_models + assert readiness.shared_base_models[0].class_name == "MyData" + + +def test_plugin_own_base_class_is_not_confused_with_shared_base(tmp_path: Path) -> None: + """插件自定义同名 Base 或继承非宿主基类不应被误报。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": ( + "class Base:\n" + " pass\n\n" + "class MyData(Base):\n" + " pass\n" + ), + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.has_shared_base_models is False + + +def test_syntax_error_file_does_not_crash_scan_and_is_recorded_as_unparsed( + tmp_path: Path, +) -> None: + """插件文件语法错误不得让扫描抛异常,应记录为无法解析并继续扫描其余文件。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": "from app.plugins.myplugin.utils import helper\n", + "broken.py": "def broken(:\n pass\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + assert readiness.unparsed_files == ("broken.py",) + assert readiness.has_self_referential_imports + assert readiness.is_clean is False + + +def test_nested_subpackage_self_import_computes_correct_relative_dots( + tmp_path: Path, +) -> None: + """深层子包内的自引用绝对 import 应算出正确的多层相对 import。""" + plugin_dir = _write_plugin( + tmp_path, + "myplugin", + { + "__init__.py": "", + "sub/__init__.py": "", + "sub/foo.py": "from app.plugins.myplugin.utils import helper\n", + "utils.py": "def helper():\n pass\n", + }, + ) + + readiness = scan_plugin_version_readiness("myplugin", plugin_dir) + + hits = [hit for hit in readiness.self_referential_imports if hit.file == "sub/foo.py"] + assert len(hits) == 1 + assert hits[0].suggestion == "from ..utils import helper" + + +def test_missing_plugin_directory_returns_empty_readiness(tmp_path: Path) -> None: + """插件目录不存在时应返回空结论而不是抛异常。""" + readiness = scan_plugin_version_readiness("ghost", tmp_path / "does-not-exist") + + assert readiness.is_clean + + +def test_multi_version_blockers_aggregates_self_referential_and_shared_base_hits( + tmp_path: Path, +) -> None: + """跨多个源码目录汇总阻断原因,只统计自引用导入与共享基类两类。""" + old_dir = _write_plugin( + tmp_path, + "old", + {"__init__.py": "from app.plugins.blockedplugin.utils import helper\n"}, + ) + new_dir = tmp_path / "new" + new_dir.mkdir() + (new_dir / "__init__.py").write_text( + "from app.db import Base\n\nclass MyData(Base):\n pass\n", + encoding="utf-8", + ) + + blockers = plugin_multi_version_blockers("blockedplugin", [old_dir, new_dir]) + + assert len(blockers) == 2 + assert any("自引用" in blocker for blocker in blockers) + assert any("共享声明基类" in blocker for blocker in blockers) + + +def test_multi_version_blockers_ignores_cross_plugin_dependency(tmp_path: Path) -> None: + """跨插件依赖不是本插件自身的写法错误,不计入并存阻断。""" + plugin_dir = _write_plugin( + tmp_path, + "consumer", + {"__init__.py": "from app.plugins.otherplugin import Thing\n"}, + ) + + blockers = plugin_multi_version_blockers("consumer", [plugin_dir]) + + assert blockers == [] + + +def test_multi_version_blockers_is_empty_for_clean_source_dirs(tmp_path: Path) -> None: + """全部源码目录都干净时不阻断多版本并存。""" + first = _write_plugin(tmp_path, "clean-a", {"__init__.py": "value = 1\n"}) + second = tmp_path / "clean-b" + second.mkdir() + (second / "__init__.py").write_text("value = 2\n", encoding="utf-8") + + assert plugin_multi_version_blockers("clean", [first, second]) == [] diff --git a/tests/test_plugin_version_recycle.py b/tests/test_plugin_version_recycle.py new file mode 100644 index 0000000000..0130425eee --- /dev/null +++ b/tests/test_plugin_version_recycle.py @@ -0,0 +1,242 @@ +"""插件版本目录回收:保留判据、保留窗口、删除安全校验与清单同步测试。""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.runtime.extensions.plugin import version as plugin_version_module +from app.runtime.extensions.plugin.version import ( + _delete_plugin_version_dir, + plugin_version_dir_name, + read_plugin_versions_manifest, + recycle_plugin_version_directories, + register_plugin_version, + write_plugin_versions_manifest, +) + + +def _install_version(plugin_root: Path, version: str) -> Path: + """在插件目录下落地一个最小版本目录并登记到已装版本清单。 + + :param plugin_root: 插件源码根目录 + :param version: 版本号,登记后成为清单的当前版本 + :return: 落地的版本目录 + """ + dir_name = plugin_version_dir_name(version) + version_dir = plugin_root / dir_name + version_dir.mkdir(parents=True) + (version_dir / "__init__.py").write_text(f"plugin_version = {version!r}\n", encoding="utf-8") + register_plugin_version(plugin_root, version, source="test") + return version_dir + + +def _stamp_installed_at(plugin_root: Path, stamps: dict[str, str]) -> None: + """把已装版本清单里各版本的登记时间改写为指定值,消除真实时钟带来的顺序不确定性。 + + :param plugin_root: 插件源码根目录 + :param stamps: 版本号到 ISO8601 时间字符串的映射 + """ + manifest = read_plugin_versions_manifest(plugin_root) + for entry in manifest["versions"]: + if entry["version"] in stamps: + entry["installed_at"] = stamps[entry["version"]] + write_plugin_versions_manifest(plugin_root, manifest["versions"], manifest["current"]) + + +# 一、保留判据 + + +def test_current_version_is_never_recycled(tmp_path: Path) -> None: + """当前安装版本即使无实例引用、保留窗口为 0 也不删除。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + + outcome = recycle_plugin_version_directories(plugin_root, referenced_versions=set(), retention=0) + + assert outcome["removed"] == [] + assert outcome["kept"]["1.0.0"] == "当前安装版本" + assert (plugin_root / "v1_0_0").is_dir() + + +def test_referenced_version_is_not_recycled(tmp_path: Path) -> None: + """被实例引用的版本不删除,即便它既不是当前版本也不在保留窗口内。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + _install_version(plugin_root, "2.0.0") + _install_version(plugin_root, "3.0.0") # 当前版本 + + outcome = recycle_plugin_version_directories( + plugin_root, referenced_versions={"1.0.0"}, retention=0 + ) + + assert outcome["removed"] == ["2.0.0"] + assert (plugin_root / "v1_0_0").is_dir() + assert (plugin_root / "v3_0_0").is_dir() + assert not (plugin_root / "v2_0_0").exists() + assert outcome["kept"]["1.0.0"].startswith("被实例引用") + + +def test_retention_window_keeps_the_n_most_recent_versions(tmp_path: Path) -> None: + """保留窗口按登记时间保留最近 N 个版本,窗口外且无引用的旧版本被回收。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + _install_version(plugin_root, "2.0.0") + _install_version(plugin_root, "3.0.0") + _stamp_installed_at( + plugin_root, + { + "1.0.0": "2020-01-01T00:00:00+00:00", + "2.0.0": "2020-06-01T00:00:00+00:00", + "3.0.0": "2021-01-01T00:00:00+00:00", + }, + ) + + outcome = recycle_plugin_version_directories(plugin_root, referenced_versions=set(), retention=2) + + assert outcome["removed"] == ["1.0.0"] + assert set(outcome["kept"]) == {"2.0.0", "3.0.0"} + assert not (plugin_root / "v1_0_0").exists() + assert (plugin_root / "v2_0_0").is_dir() + assert (plugin_root / "v3_0_0").is_dir() + + +def test_missing_installed_at_is_treated_as_oldest(tmp_path: Path) -> None: + """登记时间缺失的版本排到最旧,不占用保留窗口的名额。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + _install_version(plugin_root, "2.0.0") + manifest = read_plugin_versions_manifest(plugin_root) + for entry in manifest["versions"]: + if entry["version"] == "1.0.0": + entry.pop("installed_at", None) + write_plugin_versions_manifest(plugin_root, manifest["versions"], manifest["current"]) + + outcome = recycle_plugin_version_directories(plugin_root, referenced_versions=set(), retention=1) + + assert outcome["removed"] == ["1.0.0"] + assert set(outcome["kept"]) == {"2.0.0"} + + +def test_manifest_is_updated_after_recycling(tmp_path: Path) -> None: + """回收后已装版本清单同步剔除被删除的版本条目,当前版本指针不变。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + _install_version(plugin_root, "2.0.0") + + recycle_plugin_version_directories(plugin_root, referenced_versions=set(), retention=0) + + manifest = read_plugin_versions_manifest(plugin_root) + assert {entry["version"] for entry in manifest["versions"]} == {"2.0.0"} + assert manifest["current"] == "2.0.0" + + +def test_no_version_dirs_on_disk_is_a_no_op(tmp_path: Path) -> None: + """磁盘上没有任何版本目录时直接返回空结果,不报错。""" + plugin_root = tmp_path / "empty" + plugin_root.mkdir() + + outcome = recycle_plugin_version_directories(plugin_root, referenced_versions=set()) + + assert outcome == {"removed": [], "kept": {}} + + +# 二、删除失败隔离 + + +def test_single_directory_delete_failure_does_not_block_the_rest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """单个版本目录删除失败不影响其余版本的回收。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + _install_version(plugin_root, "2.0.0") + _install_version(plugin_root, "3.0.0") # 当前版本,受保护 + + original_rmtree = plugin_version_module.shutil.rmtree + + def flaky_rmtree(path, *args, **kwargs): + """v1_0_0 的删除永远失败,其余目录按原样删除。""" + if Path(path).name == "v1_0_0": + raise OSError("boom") + return original_rmtree(path, *args, **kwargs) + + monkeypatch.setattr(plugin_version_module.shutil, "rmtree", flaky_rmtree) + + outcome = recycle_plugin_version_directories(plugin_root, referenced_versions=set(), retention=0) + + assert outcome["removed"] == ["2.0.0"] + assert (plugin_root / "v1_0_0").is_dir() + assert not (plugin_root / "v2_0_0").exists() + assert outcome["kept"]["1.0.0"] == "本次删除失败,下次回收重试" + manifest = read_plugin_versions_manifest(plugin_root) + assert {entry["version"] for entry in manifest["versions"]} == {"1.0.0", "3.0.0"} + + +# 三、删除前的安全校验(保留条目与路径逃逸) + + +@pytest.mark.parametrize("name", ["dist", "wheels", "__pycache__"]) +def test_delete_helper_refuses_reserved_directory_names(tmp_path: Path, name: str) -> None: + """dist/wheels/__pycache__ 等保留条目反解不出版本号,删除请求被拒绝。""" + plugin_root = tmp_path / "sample" + entry = plugin_root / name + entry.mkdir(parents=True) + + deleted = _delete_plugin_version_dir(plugin_root, name, entry) + + assert deleted is False + assert entry.is_dir() + + +def test_delete_helper_refuses_a_directory_name_mismatch(tmp_path: Path) -> None: + """目录名反解出的版本号与待删除版本不一致时拒绝删除。""" + plugin_root = tmp_path / "sample" + version_dir = plugin_root / "v1_0_0" + version_dir.mkdir(parents=True) + + deleted = _delete_plugin_version_dir(plugin_root, "2.0.0", version_dir) + + assert deleted is False + assert version_dir.is_dir() + + +def test_delete_helper_refuses_a_directory_outside_the_plugin_root(tmp_path: Path) -> None: + """目录 resolve() 后位于插件目录之外时拒绝删除,不触发 rmtree。""" + plugin_root = tmp_path / "sample" + plugin_root.mkdir() + outside = tmp_path / "v9_9_9" + outside.mkdir() + (outside / "marker.txt").write_text("keep", encoding="utf-8") + + deleted = _delete_plugin_version_dir(plugin_root, "9.9.9", outside) + + assert deleted is False + assert (outside / "marker.txt").exists() + + +def test_delete_helper_refuses_the_plugin_root_itself(tmp_path: Path) -> None: + """待删除目录就是插件目录本身时拒绝删除,即便名字碰巧能反解出版本号。""" + plugin_root = tmp_path / "v1_0_0" + plugin_root.mkdir() + (plugin_root / "marker.txt").write_text("keep", encoding="utf-8") + + deleted = _delete_plugin_version_dir(plugin_root, "1.0.0", plugin_root) + + assert deleted is False + assert (plugin_root / "marker.txt").exists() + + +def test_recycle_leaves_reserved_entries_alone(tmp_path: Path) -> None: + """完整回收流程中,保留条目不会被当成版本目录考虑或删除。""" + plugin_root = tmp_path / "sample" + _install_version(plugin_root, "1.0.0") + _install_version(plugin_root, "2.0.0") + for reserved in ("dist", "wheels", "__pycache__"): + (plugin_root / reserved).mkdir() + + recycle_plugin_version_directories(plugin_root, referenced_versions=set(), retention=0) + + for reserved in ("dist", "wheels", "__pycache__"): + assert (plugin_root / reserved).is_dir() diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index 94d68e3bd5..77ef405fb7 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -5,11 +5,31 @@ from app.runtime.extensions.plugin.clone import PluginCloneService from app.runtime.extensions.plugin.loader import PluginLoader -from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage +from app.runtime.extensions.plugin.storage import ( + PluginInstanceDirectory, + PluginInstanceStore, + PluginStorage, +) from app.schemas.plugin import PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey +def _make_directory() -> PluginInstanceDirectory: + """构造进程内插件实例描述符表,供分身持久化测试使用。""" + records: dict[str, PluginInstance] = {} + return PluginInstanceDirectory( + get=records.get, + list_all=lambda: list(records.values()), + list_by_source=lambda source_plugin_id: [ + record + for record in records.values() + if record.source_plugin_id == source_plugin_id + ], + save=lambda instance: records.__setitem__(instance.instance_id, instance), + delete=lambda instance_id: records.pop(instance_id, None) is not None, + ) + + def _logger() -> SimpleNamespace: """提供加载器测试所需的最小日志对象。""" return SimpleNamespace( @@ -27,7 +47,8 @@ def test_instance_store_keeps_virtual_instances_out_of_installed_list(): read=values.get, write=lambda key, value: values.__setitem__(key, value), ) - store = PluginInstanceStore(storage=lambda: storage) + directory = _make_directory() + store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) instance = PluginInstance( instance_id="DemoPluginWork", @@ -43,6 +64,66 @@ def test_instance_store_keeps_virtual_instances_out_of_installed_list(): assert store.all() == {} +def test_host_binding_does_not_leak_into_clone_list_or_installed_list(): + """本体的版本绑定与分身共用一张表,但不得出现在分身清单或已安装清单里。""" + values = {SystemConfigKey.UserInstalledPlugins: ["DemoPlugin"]} + storage = PluginStorage( + read=values.get, + write=lambda key, value: values.__setitem__(key, value), + ) + directory = _make_directory() + store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + store.save(clone) + + store.save_host( + PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + follow_current_version=False, + plugin_version="1.0.0", + ) + ) + + assert store.all() == {"DemoPluginWork": clone} + assert store.for_source("DemoPlugin") == [clone] + assert store.get("DemoPlugin") is None + assert values[SystemConfigKey.UserInstalledPlugins] == ["DemoPlugin"] + assert store.get_host("DemoPlugin") is not None + + +def test_all_hosts_batches_host_binding_records_without_leaking_clones(): + """``all_hosts`` 一次性返回全部本体绑定记录,且不得混入分身实例。""" + values = {SystemConfigKey.UserInstalledPlugins: ["DemoPlugin", "OtherPlugin"]} + storage = PluginStorage( + read=values.get, + write=lambda key, value: values.__setitem__(key, value), + ) + directory = _make_directory() + store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + store.save(PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin")) + demo_host = PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + follow_current_version=False, + plugin_version="1.0.0", + ) + other_host = PluginInstance( + instance_id="OtherPlugin", + source_plugin_id="OtherPlugin", + is_default_target=True, + ) + store.save_host(demo_host) + store.save_host(other_host) + + hosts = store.all_hosts() + + assert hosts == { + "DemoPlugin": demo_host.model_copy(update={"mode": "host"}), + "OtherPlugin": other_host.model_copy(update={"mode": "host"}), + } + + def test_loader_executes_each_instance_in_an_isolated_module_namespace( tmp_path, monkeypatch, diff --git a/tests/test_pluginversion_endpoint.py b/tests/test_pluginversion_endpoint.py new file mode 100644 index 0000000000..3a8c320c2f --- /dev/null +++ b/tests/test_pluginversion_endpoint.py @@ -0,0 +1,239 @@ +"""插件版本查询与实例版本绑定切换接口测试。""" + +from __future__ import annotations + +import inspect + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.endpoints import pluginversion as pluginversion_endpoint +from app.api.endpoints.pluginversion import ( + plugin_version_overview, + recycle_plugin_versions, + set_plugin_instance_version, +) +from app.schemas.exception import PluginMutationRejectedError +from app.schemas.plugin import PluginInstanceVersionUpdateRequest + + +def _depends_default(func, parameter_name: str): + """取出端点函数指定参数的 FastAPI Depends 默认值。""" + return inspect.signature(func).parameters[parameter_name].default + + +def test_all_endpoints_require_superuser_dependency(): + """三个端点都要求超级管理员,不能被低权限用户直接调用。""" + for func in (plugin_version_overview, set_plugin_instance_version, recycle_plugin_versions): + depends = _depends_default(func, "_") + assert depends.dependency is get_current_active_superuser + + +def test_plugin_version_overview_returns_manager_result(monkeypatch): + """接口把 Manager 组装好的总览原样透传给调用方。""" + overview = { + "plugin_id": "DemoPlugin", + "current_version": "2.0.0", + "installed_versions": [], + "instances": [], + } + manager = type( + "Manager", + (), + {"get_plugin_version_overview": lambda self, _plugin_id: overview}, + )() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = plugin_version_overview("DemoPlugin", None) + + assert result.success is True + assert result.data == overview + + +def test_plugin_version_overview_reports_missing_plugin(monkeypatch): + """插件不存在时返回失败响应,而不是让异常穿透接口。""" + + def _raise(_plugin_id): + raise LookupError("插件 Missing 不存在") + + manager = type("Manager", (), {"get_plugin_version_overview": lambda self, plugin_id: _raise(plugin_id)})() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = plugin_version_overview("Missing", None) + + assert result.success is False + assert "不存在" in result.message + + +def test_set_plugin_instance_version_rejects_instance_outside_plugin(monkeypatch): + """目标实例不在该插件的绑定列表中时拒绝切换,不下发到 Manager 层。""" + overview = { + "plugin_id": "DemoPlugin", + "current_version": "1.0.0", + "installed_versions": [], + "instances": [{"instance_id": "OtherWork", "plugin_version": None, "follow_current_version": True, "running": False}], + } + calls: list = [] + manager = type( + "Manager", + (), + { + "get_plugin_version_overview": lambda self, _plugin_id: overview, + "set_plugin_instance_version": lambda self, *a, **kw: calls.append((a, kw)), + }, + )() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = set_plugin_instance_version( + "DemoPlugin", + "DemoPluginWork", + PluginInstanceVersionUpdateRequest(follow_current_version=True), + None, + ) + + assert result.success is False + assert "不存在" in result.message + assert calls == [] + + +def test_set_plugin_instance_version_delegates_to_manager_and_reports_success(monkeypatch): + """已知实例的切换请求原样转交给 Manager,并按其结果返回成功响应。""" + overview = { + "plugin_id": "DemoPlugin", + "current_version": "1.0.0", + "installed_versions": [], + "instances": [{"instance_id": "DemoPluginWork", "plugin_version": "1.0.0", "follow_current_version": False, "running": True}], + } + calls: list = [] + + def _set_version(self, instance_id, *, follow_current_version, plugin_version=None): + calls.append((instance_id, follow_current_version, plugin_version)) + return True, instance_id + + manager = type( + "Manager", + (), + { + "get_plugin_version_overview": lambda self, _plugin_id: overview, + "set_plugin_instance_version": _set_version, + }, + )() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = set_plugin_instance_version( + "DemoPlugin", + "DemoPluginWork", + PluginInstanceVersionUpdateRequest(follow_current_version=False, plugin_version="2.0.0"), + None, + ) + + assert result.success is True + assert result.message == "版本切换成功" + assert calls == [("DemoPluginWork", False, "2.0.0")] + + +def test_set_plugin_instance_version_propagates_manager_failure_message(monkeypatch): + """Manager 拒绝切换时把可读原因原样返回给调用方。""" + overview = { + "plugin_id": "DemoPlugin", + "current_version": "1.0.0", + "installed_versions": [], + "instances": [{"instance_id": "DemoPluginWork", "plugin_version": "1.0.0", "follow_current_version": False, "running": True}], + } + manager = type( + "Manager", + (), + { + "get_plugin_version_overview": lambda self, _plugin_id: overview, + "set_plugin_instance_version": lambda self, *a, **kw: ( + False, + "插件 DemoPlugin 未安装版本 9.9.9", + ), + }, + )() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = set_plugin_instance_version( + "DemoPlugin", + "DemoPluginWork", + PluginInstanceVersionUpdateRequest(follow_current_version=False, plugin_version="9.9.9"), + None, + ) + + assert result.success is False + assert result.message == "插件 DemoPlugin 未安装版本 9.9.9" + + +def test_set_plugin_instance_version_reports_missing_plugin(monkeypatch): + """插件本身不存在时同样返回失败响应,不下发到实例切换逻辑。""" + + def _raise(_plugin_id): + raise LookupError("插件 Missing 不存在") + + manager = type("Manager", (), {"get_plugin_version_overview": lambda self, plugin_id: _raise(plugin_id)})() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = set_plugin_instance_version( + "Missing", + "MissingWork", + PluginInstanceVersionUpdateRequest(follow_current_version=True), + None, + ) + + assert result.success is False + assert "不存在" in result.message + + +def test_recycle_plugin_versions_returns_manager_outcome(monkeypatch): + """接口把 Manager 回收结果原样透传给调用方。""" + outcome = {"removed": ["1.0.0"], "kept": {"2.0.0": "当前安装版本"}} + manager = type( + "Manager", + (), + {"recycle_plugin_versions": lambda self, _plugin_id: outcome}, + )() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = recycle_plugin_versions("DemoPlugin", None) + + assert result.success is True + assert result.data == outcome + + +def test_recycle_plugin_versions_reports_missing_plugin(monkeypatch): + """插件不存在时返回失败响应,而不是让异常穿透接口。""" + + def _raise(_plugin_id): + raise LookupError("插件 Missing 不存在") + + manager = type("Manager", (), {"recycle_plugin_versions": lambda self, plugin_id: _raise(plugin_id)})() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = recycle_plugin_versions("Missing", None) + + assert result.success is False + assert "不存在" in result.message + + +def test_recycle_plugin_versions_reports_mutation_rejection(monkeypatch): + """并发窗口拒绝本次回收时返回失败响应,而不是让异常穿透接口。""" + + def _raise(_plugin_id): + raise PluginMutationRejectedError("插件正在结算,暂不接受回收") + + manager = type("Manager", (), {"recycle_plugin_versions": lambda self, plugin_id: _raise(plugin_id)})() + monkeypatch.setattr(pluginversion_endpoint, "get_plugin_manager", lambda: manager) + + result = recycle_plugin_versions("DemoPlugin", None) + + assert result.success is False + assert "暂不接受回收" in result.message + + +def test_router_registers_all_paths(): + """路由器暴露版本总览、实例切换、回收与日志等级控制路径,且注册在插件前缀下。""" + paths = {route.path for route in pluginversion_endpoint.router.routes} + assert "/versions/{plugin_id}" in paths + assert "/versions/{plugin_id}/{instance_id}" in paths + assert "/versions/{plugin_id}/recycle" in paths + assert "/loglevel/{plugin_id}" in paths + assert "/loglevel/{plugin_id}/{instance_id}" in paths + assert "/instances/{plugin_id}/{instance_id}/default_target" in paths diff --git a/tests/test_workflow_invoke_plugin.py b/tests/test_workflow_invoke_plugin.py index 0e8938b18e..a113947c78 100644 --- a/tests/test_workflow_invoke_plugin.py +++ b/tests/test_workflow_invoke_plugin.py @@ -52,3 +52,73 @@ def test_invoke_plugin_keeps_legacy_action_id_fallback() -> None: _, result = _execute_with_action({"action_id": "cleanup"}) assert result.content == "before" + + +def test_invoke_plugin_dispatches_to_resolved_default_target() -> None: + """插件 ID 有分身时,动作按裁决出的默认调用目标查询动作,而不是原样使用插件 ID。 + + 这是历史工作流在源插件本体停用、仅分身启用后仍能继续工作的关键:存量工作流 + 保存的还是物理插件 ID,必须经过默认调用目标裁决才能落到实际在跑的分身上。 + """ + context = ActionContext(content="before") + action = {"id": "cleanup"} + action_fn = Mock(return_value=(True, context)) + action["func"] = action_fn + plugin_manager = Mock() + plugin_manager.resolve_plugin_call_target.return_value = "plugin-a-clone" + plugin_manager.get_plugin_actions.return_value = [ + {"plugin_id": "plugin-a-clone", "actions": [action]} + ] + + with patch( + "app.workflow.actions.get_configured_system_config", + return_value=Mock(), + ), patch( + "app.workflow.actions.invoke_plugin.get_plugin_manager", + return_value=plugin_manager, + ): + action_runner = InvokePluginAction("invoke") + action_runner.execute( + workflow_id=1, + params={ + "plugin_id": "plugin-a", + "action_id": "cleanup", + "action_params": {}, + }, + context=context, + ) + + plugin_manager.resolve_plugin_call_target.assert_called_once_with("plugin-a") + plugin_manager.get_plugin_actions.assert_called_once_with("plugin-a-clone") + assert action_runner.success is True + + +def test_invoke_plugin_fails_gracefully_when_default_target_undecidable() -> None: + """裁决报错(未设默认目标/默认目标已停用)时动作失败但不抛出,不误执行任何实例。""" + context = ActionContext(content="before") + plugin_manager = Mock() + plugin_manager.resolve_plugin_call_target.side_effect = LookupError( + "插件 plugin-a 未设置默认实例,调用必须显式指定实例;可选实例:a(已启用)、b(已启用)" + ) + + with patch( + "app.workflow.actions.get_configured_system_config", + return_value=Mock(), + ), patch( + "app.workflow.actions.invoke_plugin.get_plugin_manager", + return_value=plugin_manager, + ): + action_runner = InvokePluginAction("invoke") + result = action_runner.execute( + workflow_id=1, + params={ + "plugin_id": "plugin-a", + "action_id": "cleanup", + "action_params": {}, + }, + context=context, + ) + + plugin_manager.get_plugin_actions.assert_not_called() + assert action_runner.success is False + assert result.content == "before"