mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
物理分身是早期实现:复制整个插件目录,再用字符串替换改写副本源码里的类名、
plugin_config_prefix 和联邦构建产物。虚拟实例接管后,分身不再复制目录,
由装载器改写类身份、按实例 ID 隔离配置与数据,这套改写器随之失去调用者。
求证依据(全仓 git grep,覆盖 app/、tests/、scripts/、docs/、skills/):
- PluginPackageManager.clone() 无任何生产调用者。分身的唯一生产路径是
POST /api/v1/plugin/clone/{plugin_id} -> PluginManager.clone_plugin()
-> PluginCloneService.clone(),后者只写 PluginInstance 记录与隔离配置,
不碰包适配器。clone() 也没有经 PluginSystemServices 暴露出去。
- clone() 的四个私有实现 _modify_plugin_files / _modify_python_file /
_modify_federation_files / _rename_federation_assets,消费者只有 clone()
自身与 PluginSystemServices 的同名透传。
- PluginSystemServices 的四个透传,消费者只有 PluginManager 的 _modify_* shim。
- PluginManager 的四个 shim 无任何调用者。动态可达性已单独排查:
全仓没有 getattr/字符串反射指向这些名字;app/runtime/compat/manifest.py
不登记它们的别名(只有 app.core.plugin -> 本模块的模块级映射);
app/sdk/ 不再导出;PluginManager 与 PluginPackageManager 都没有
__getattr__ 兜底,属性不存在即 AttributeError;官方插件基线
official-plugin-baseline.json 零命中。
- @observe_compat_facade("PluginManager") 只包装类上已存在的方法,
不会凭空合成属性,因此 shim 删除后 enforce_facade 再也匹配不到
notices.py 里那四条 key,登记随之成为不可达数据,一并删除。
替代路径:这四条弃用声明当初承诺的替代目标是
get_plugin_system().package._modify_*(),那是一次「门面搬到适配器」的
重定位登记,本次连同能力一起下线;分身能力本身的替代路径是虚拟实例
PluginCloneService.clone(),它已在生产链路上,见
docs/v2-to-v3-overview.md §3.6「不复制插件源码,也能保持各实例配置隔离」。
既有磁盘上的旧物理分身不受影响:卸载路径读取 is_clone 属性并调用
remove_plugin_package() 的逻辑原样保留,只是不再产生新的物理分身。
这是对已公告弃用 API(DeprecationStage.SILENT,since v3.0.0)的正式移除。
连带改动:
- package.py 的 import re 仅服务于 _modify_python_file,同步移除;模块
docstring 去掉「分身处理」。
- system.py 的 typing.cast 仅服务于被删透传,同步移除。
- tests/test_plugin_package_manager.py::test_clone_rewrites_python_and_federation_assets
是这套改写器的唯一测试,且只覆盖已无调用者的路径,随实现一并删除。
(删的是测试函数不是测试文件,tests/run.py 的分片装箱不变。)
- mypy 低水位随实现下降:app/runtime/extensions/plugin/manager.py 的
assignment 8->4(四个 shim 的 version/icon 参数 str = None)、
no-untyped-def 12->11(_rename_federation_assets 缺返回标注),
fixture 与 docs/refactor/optimization-checklist.md 的全量 mypy 计数
9,371 -> 9,366 同步更新。依赖、复杂度、ruff、并发等其余基线无变化。
Co-authored-by: jxxghp <jxxghp@gmail.com>
450 lines
16 KiB
Python
450 lines
16 KiB
Python
import errno
|
|
import shutil
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from app.adapters.system.plugin.package import PluginPackageManager
|
|
from app.runtime.dependencies.native import LoadedNativeDependencySnapshot
|
|
|
|
|
|
def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager:
|
|
"""构造使用隔离运行目录和事务目录的插件包管理器。"""
|
|
settings = SimpleNamespace(
|
|
ROOT_PATH=tmp_path,
|
|
TEMP_PATH=tmp_path / "temp",
|
|
CONFIG_PATH=tmp_path / "config",
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.get_runtime_setting",
|
|
lambda key: getattr(settings, key),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.capture_loaded_native_dependencies",
|
|
LoadedNativeDependencySnapshot,
|
|
)
|
|
return PluginPackageManager(source=Mock())
|
|
|
|
|
|
def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path):
|
|
"""已存在插件在后续阶段失败时应完整恢复原文件。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
source_file = plugin_dir / "__init__.py"
|
|
source_file.write_text("old", encoding="utf-8")
|
|
|
|
checkpoint = manager.checkpoint("DemoPlugin")
|
|
source_file.write_text("new", encoding="utf-8")
|
|
(plugin_dir / "partial.py").write_text("partial", encoding="utf-8")
|
|
manager.rollback(checkpoint)
|
|
|
|
assert source_file.read_text(encoding="utf-8") == "old"
|
|
assert not (plugin_dir / "partial.py").exists()
|
|
assert not checkpoint.transaction_dir.exists()
|
|
|
|
|
|
def test_checkpoint_rollback_handles_cross_device_plugin_directory(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""overlayfs 拒绝目录 rename 时仍应恢复旧插件并清理事务。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
source_file = plugin_dir / "__init__.py"
|
|
source_file.write_text("old", encoding="utf-8")
|
|
|
|
checkpoint = manager.checkpoint("DemoPlugin")
|
|
source_file.write_text("new", encoding="utf-8")
|
|
(plugin_dir / "partial.py").write_text("partial", encoding="utf-8")
|
|
|
|
original_replace = Path.replace
|
|
|
|
def replace(self: Path, target: Path) -> Path:
|
|
"""只模拟运行目录跨设备 rename 失败,保留其他替换操作。"""
|
|
if self == plugin_dir and target.name.startswith(
|
|
".demoplugin.previous-"
|
|
):
|
|
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
|
return original_replace(self, target)
|
|
|
|
monkeypatch.setattr(Path, "replace", replace)
|
|
manager.rollback(checkpoint)
|
|
|
|
assert source_file.read_text(encoding="utf-8") == "old"
|
|
assert not (plugin_dir / "partial.py").exists()
|
|
assert not checkpoint.transaction_dir.exists()
|
|
|
|
|
|
def test_checkpoint_uses_injected_plugin_root(monkeypatch, tmp_path):
|
|
"""显式装配的插件根目录必须覆盖全局运行目录设置。"""
|
|
_manager(monkeypatch, tmp_path)
|
|
plugin_root = tmp_path / "custom-plugins"
|
|
plugin_dir = plugin_root / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("custom", encoding="utf-8")
|
|
manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root)
|
|
|
|
checkpoint = manager.checkpoint("DemoPlugin")
|
|
|
|
assert checkpoint.plugin_dir == plugin_dir.resolve()
|
|
assert (checkpoint.transaction_dir / "package" / "__init__.py").read_text(
|
|
encoding="utf-8"
|
|
) == "custom"
|
|
manager.commit(checkpoint)
|
|
|
|
|
|
def test_remove_plugin_uses_package_owner_path_boundary(tmp_path):
|
|
"""物理卸载只删除注入根目录内的目标插件。"""
|
|
plugin_root = tmp_path / "plugins"
|
|
plugin_dir = plugin_root / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("plugin", encoding="utf-8")
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root)
|
|
|
|
assert manager.remove_plugin("DemoPlugin") is True
|
|
assert not plugin_dir.exists()
|
|
assert outside.exists()
|
|
assert manager.remove_plugin("DemoPlugin") is False
|
|
with pytest.raises(ValueError, match="非法插件ID"):
|
|
manager.remove_plugin("../outside")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("remote_path", "package_version"),
|
|
[
|
|
("../escaped.py", None),
|
|
("/tmp/escaped.py", None),
|
|
("C:\\escaped.py", None),
|
|
("plugins/other/file.py", None),
|
|
("plugins.v2/demoplugin/../escaped.py", "v2"),
|
|
],
|
|
)
|
|
def test_file_list_download_rejects_paths_outside_plugin_root(
|
|
monkeypatch,
|
|
tmp_path,
|
|
remote_path,
|
|
package_version,
|
|
):
|
|
"""同步文件列表安装不得把远端路径写到当前插件目录之外。"""
|
|
plugin_root = tmp_path / "plugins"
|
|
manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root)
|
|
request = Mock()
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_PluginPackageManager__request_with_fallback",
|
|
request,
|
|
)
|
|
|
|
result = manager._PluginPackageManager__download_files(
|
|
"DemoPlugin",
|
|
[{"path": remote_path, "download_url": "https://example.invalid/file"}],
|
|
"owner/repo",
|
|
package_version,
|
|
)
|
|
|
|
assert result == (False, "插件文件路径无效")
|
|
request.assert_not_called()
|
|
assert not (tmp_path / "escaped.py").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_file_list_download_rejects_path_outside_plugin_root(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""异步文件列表安装复用同一受控路径边界。"""
|
|
manager = PluginPackageManager(source=Mock(), plugin_root=tmp_path / "plugins")
|
|
request = AsyncMock()
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_PluginPackageManager__async_request_with_fallback",
|
|
request,
|
|
)
|
|
|
|
result = await manager._PluginPackageManager__async_download_files(
|
|
"DemoPlugin",
|
|
[
|
|
{
|
|
"path": "plugins.v2/demoplugin/../../escaped.py",
|
|
"download_url": "https://example.invalid/file",
|
|
}
|
|
],
|
|
"owner/repo",
|
|
"v2",
|
|
)
|
|
|
|
assert result == (False, "插件文件路径无效")
|
|
request.assert_not_awaited()
|
|
assert not (tmp_path / "escaped.py").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_list_download_rejects_traversal_directory_names(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""目录项名称不得扩大后续市场查询到当前插件树之外。"""
|
|
manager = PluginPackageManager(source=Mock(), plugin_root=tmp_path / "plugins")
|
|
sync_query = Mock()
|
|
async_query = AsyncMock()
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_PluginPackageManager__get_file_list",
|
|
sync_query,
|
|
)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_PluginPackageManager__async_get_file_list",
|
|
async_query,
|
|
)
|
|
item = {"name": "..", "download_url": None}
|
|
|
|
assert manager._PluginPackageManager__download_files(
|
|
"DemoPlugin", [item], "owner/repo"
|
|
) == (False, "插件目录路径无效")
|
|
assert await manager._PluginPackageManager__async_download_files(
|
|
"DemoPlugin", [item], "owner/repo"
|
|
) == (False, "插件目录路径无效")
|
|
sync_query.assert_not_called()
|
|
async_query.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_file_list_download_preserves_binary_payload_in_injected_plugin_root(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""同步与异步文件列表都应在受控根目录内保留原始文件字节。"""
|
|
plugin_root = tmp_path / "plugins"
|
|
payload = b"\x00\xffwheel-payload"
|
|
response = SimpleNamespace(status_code=200, content=payload, text="wrong-text")
|
|
manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_PluginPackageManager__request_with_fallback",
|
|
Mock(return_value=response),
|
|
)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_PluginPackageManager__async_request_with_fallback",
|
|
AsyncMock(return_value=response),
|
|
)
|
|
item = {
|
|
"path": "plugins.v2/demoplugin/nested/file.py",
|
|
"download_url": "https://example.invalid/file",
|
|
}
|
|
|
|
assert manager._PluginPackageManager__download_files(
|
|
"DemoPlugin", [item], "owner/repo", "v2"
|
|
) == (True, "")
|
|
assert await manager._PluginPackageManager__async_download_files(
|
|
"DemoPlugin", [item], "owner/repo", "v2"
|
|
) == (True, "")
|
|
assert (plugin_root / "demoplugin" / "nested" / "file.py").read_bytes() == payload
|
|
|
|
|
|
def test_checkpoint_does_not_scan_native_dependencies(monkeypatch, tmp_path):
|
|
"""普通插件文件快照不应枚举宿主全部原生发行包。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
capture = Mock()
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.capture_loaded_native_dependencies",
|
|
capture,
|
|
)
|
|
|
|
checkpoint = manager.checkpoint("DemoPlugin")
|
|
|
|
assert checkpoint.native_dependencies is None
|
|
capture.assert_not_called()
|
|
assert manager.native_dependency_changes(checkpoint) == ()
|
|
|
|
|
|
def test_checkpoint_rollback_removes_new_package(monkeypatch, tmp_path):
|
|
"""首次安装失败时应删除安装过程创建的不完整目录。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
checkpoint = manager.checkpoint("DemoPlugin")
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("partial", encoding="utf-8")
|
|
|
|
manager.rollback(checkpoint)
|
|
|
|
assert not plugin_dir.exists()
|
|
assert not checkpoint.transaction_dir.exists()
|
|
|
|
|
|
def test_rollback_does_not_delete_package_when_snapshot_is_missing(monkeypatch, tmp_path):
|
|
"""补偿快照损坏时先失败,不能先删除当前可用插件。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("old", encoding="utf-8")
|
|
|
|
checkpoint = manager.checkpoint("DemoPlugin")
|
|
shutil.rmtree(checkpoint.transaction_dir / "package")
|
|
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
manager.rollback(checkpoint)
|
|
|
|
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "new"
|
|
|
|
|
|
def test_durable_checkpoint_stages_backup_without_overwriting_current_backup(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""数据库提交前只准备新备份,现有容器恢复材料保持可用。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
|
lambda: True,
|
|
)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
backup_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
|
|
(backup_dir / "__init__.py").write_text("old", encoding="utf-8")
|
|
|
|
checkpoint = manager.checkpoint("DemoPlugin", "txn-1")
|
|
manager.stage_persistent_backup(checkpoint)
|
|
|
|
assert checkpoint.transaction_dir.parent == tmp_path / "config" / "plugin_transactions"
|
|
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old"
|
|
assert checkpoint.backup_staging_dir is not None
|
|
assert (checkpoint.backup_staging_dir / "__init__.py").read_text(
|
|
encoding="utf-8"
|
|
) == "new"
|
|
|
|
|
|
def test_activate_and_finalize_persistent_backup_are_retryable(monkeypatch, tmp_path):
|
|
"""备份激活保留旧载荷,数据库提交后的清理可以重复执行。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
|
lambda: True,
|
|
)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
backup_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
|
|
(backup_dir / "__init__.py").write_text("old", encoding="utf-8")
|
|
checkpoint = manager.checkpoint("DemoPlugin", "txn-2")
|
|
manager.stage_persistent_backup(checkpoint)
|
|
|
|
manager.activate_persistent_backup(checkpoint)
|
|
manager.activate_persistent_backup(checkpoint)
|
|
|
|
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "new"
|
|
assert checkpoint.backup_staging_dir is not None
|
|
assert not checkpoint.backup_staging_dir.exists()
|
|
assert checkpoint.backup_previous_dir is not None
|
|
assert (checkpoint.backup_previous_dir / "__init__.py").read_text(
|
|
encoding="utf-8"
|
|
) == "old"
|
|
|
|
manager.finalize_persistent_backup(checkpoint)
|
|
manager.finalize_persistent_backup(checkpoint)
|
|
|
|
assert not checkpoint.backup_previous_dir.exists()
|
|
|
|
|
|
def test_rollback_removes_staging_but_preserves_current_backup(monkeypatch, tmp_path):
|
|
"""提交前失败只恢复运行目录,不修改上一份容器恢复备份。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
|
lambda: True,
|
|
)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
backup_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8")
|
|
(backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8")
|
|
checkpoint = manager.checkpoint("DemoPlugin", "txn-3")
|
|
(plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8")
|
|
manager.stage_persistent_backup(checkpoint)
|
|
|
|
manager.rollback(checkpoint)
|
|
|
|
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime"
|
|
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup"
|
|
assert checkpoint.backup_staging_dir is not None
|
|
assert not checkpoint.backup_staging_dir.exists()
|
|
|
|
|
|
def test_rollback_after_backup_activation_restores_previous_backup(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""数据库提交前失败时,已激活的新备份必须回退到上一份载荷。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
|
lambda: True,
|
|
)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
backup_dir.mkdir(parents=True)
|
|
(plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8")
|
|
(backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8")
|
|
checkpoint = manager.checkpoint("DemoPlugin", "txn-4")
|
|
(plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8")
|
|
manager.stage_persistent_backup(checkpoint)
|
|
manager.activate_persistent_backup(checkpoint)
|
|
|
|
manager.rollback(checkpoint)
|
|
|
|
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime"
|
|
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup"
|
|
|
|
|
|
def test_restore_checkpoint_derives_only_controlled_paths(monkeypatch, tmp_path):
|
|
"""崩溃回放只按事务 ID 在受控根目录内重建文件引用。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(
|
|
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
|
lambda: True,
|
|
)
|
|
|
|
checkpoint = manager.restore_checkpoint(
|
|
plugin_id="DemoPlugin",
|
|
transaction_id="txn-5",
|
|
plugin_existed=True,
|
|
persistent_backup_existed=False,
|
|
)
|
|
|
|
assert checkpoint.transaction_dir == (
|
|
tmp_path / "config" / "plugin_transactions" / "txn-5"
|
|
)
|
|
assert checkpoint.backup_staging_dir == (
|
|
tmp_path / "config" / "plugins_backup" / ".demoplugin.staging-txn-5"
|
|
)
|
|
assert checkpoint.backup_previous_dir == (
|
|
tmp_path / "config" / "plugins_backup" / ".demoplugin.previous-txn-5"
|
|
)
|
|
|
|
|
|
def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path):
|
|
"""本地来源不可复制时不得丢失已经运行的插件副本。"""
|
|
manager = _manager(monkeypatch, tmp_path)
|
|
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
source_file = plugin_dir / "__init__.py"
|
|
source_file.write_text("stable", encoding="utf-8")
|
|
missing_source = tmp_path / "missing" / "demoplugin"
|
|
|
|
assert manager.sync_local("DemoPlugin", missing_source) is False
|
|
|
|
assert source_file.read_text(encoding="utf-8") == "stable"
|