feat: 添加插件配置版本管理,优化配置归一化流程
This commit is contained in:
@@ -26,6 +26,8 @@ from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest
|
|||||||
class _DemoConfigPlugin:
|
class _DemoConfigPlugin:
|
||||||
"""用于测试 Runner 配置归一化流程的伪插件。"""
|
"""用于测试 Runner 配置归一化流程的伪插件。"""
|
||||||
|
|
||||||
|
_config_version: str = "2.0.0"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""初始化测试插件状态。"""
|
"""初始化测试插件状态。"""
|
||||||
|
|
||||||
@@ -43,7 +45,8 @@ class _DemoConfigPlugin:
|
|||||||
|
|
||||||
current_config = dict(config_data or {})
|
current_config = dict(config_data or {})
|
||||||
plugin_section = dict(current_config.get("plugin", {}))
|
plugin_section = dict(current_config.get("plugin", {}))
|
||||||
changed = "retry_count" not in plugin_section
|
changed = "retry_count" not in plugin_section or "config_version" not in plugin_section
|
||||||
|
plugin_section.setdefault("config_version", self._config_version)
|
||||||
plugin_section.setdefault("enabled", True)
|
plugin_section.setdefault("enabled", True)
|
||||||
plugin_section.setdefault("retry_count", 3)
|
plugin_section.setdefault("retry_count", 3)
|
||||||
return {"plugin": plugin_section}, changed
|
return {"plugin": plugin_section}, changed
|
||||||
@@ -64,7 +67,7 @@ class _DemoConfigPlugin:
|
|||||||
Dict[str, Any]: 默认配置字典。
|
Dict[str, Any]: 默认配置字典。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return {"plugin": {"enabled": True, "retry_count": 3}}
|
return {"plugin": {"config_version": self._config_version, "enabled": True, "retry_count": 3}}
|
||||||
|
|
||||||
def get_webui_config_schema(
|
def get_webui_config_schema(
|
||||||
self,
|
self,
|
||||||
@@ -131,6 +134,7 @@ class _StrictConfigPlugin:
|
|||||||
|
|
||||||
current_config = dict(config_data or {})
|
current_config = dict(config_data or {})
|
||||||
plugin_section = dict(current_config.get("plugin", {}))
|
plugin_section = dict(current_config.get("plugin", {}))
|
||||||
|
plugin_section.setdefault("config_version", "2.0.0")
|
||||||
retry_count = int(plugin_section.get("retry_count", 0))
|
retry_count = int(plugin_section.get("retry_count", 0))
|
||||||
if retry_count < 0:
|
if retry_count < 0:
|
||||||
raise ValueError("重试次数不能小于 0")
|
raise ValueError("重试次数不能小于 0")
|
||||||
@@ -146,6 +150,15 @@ class _StrictConfigPlugin:
|
|||||||
|
|
||||||
del config
|
del config
|
||||||
|
|
||||||
|
def get_default_config(self) -> Dict[str, Any]:
|
||||||
|
"""返回测试插件的默认配置。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 默认配置字典。
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 0}}
|
||||||
|
|
||||||
|
|
||||||
def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None:
|
def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None:
|
||||||
"""Runner 注入配置时应自动补齐并落盘 config.toml。"""
|
"""Runner 注入配置时应自动补齐并落盘 config.toml。"""
|
||||||
@@ -158,19 +171,22 @@ def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> Non
|
|||||||
)
|
)
|
||||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||||
|
|
||||||
runner._apply_plugin_config(cast(Any, meta), config_data={"plugin": {"enabled": False}})
|
runner._apply_plugin_config(
|
||||||
|
cast(Any, meta),
|
||||||
|
config_data={"plugin": {"config_version": "2.0.0", "enabled": False}},
|
||||||
|
)
|
||||||
|
|
||||||
config_path = tmp_path / "config.toml"
|
config_path = tmp_path / "config.toml"
|
||||||
assert config_path.exists()
|
assert config_path.exists()
|
||||||
assert plugin.received_config == {"plugin": {"enabled": False, "retry_count": 3}}
|
assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
|
|
||||||
with config_path.open("rb") as handle:
|
with config_path.open("rb") as handle:
|
||||||
saved_config = tomllib.load(handle)
|
saved_config = tomllib.load(handle)
|
||||||
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
|
assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
|
|
||||||
|
|
||||||
def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) -> None:
|
def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) -> None:
|
||||||
"""Runner 补齐配置时应尽量保留现有 config.toml 注释。"""
|
"""Runner 在版本升级时应尽量保留现有 config.toml 注释。"""
|
||||||
|
|
||||||
plugin = _DemoConfigPlugin()
|
plugin = _DemoConfigPlugin()
|
||||||
runner = PluginRunner(
|
runner = PluginRunner(
|
||||||
@@ -181,7 +197,7 @@ def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path)
|
|||||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||||
config_path = tmp_path / "config.toml"
|
config_path = tmp_path / "config.toml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
'# 插件配置头注释\n[plugin]\nenabled = false # 启用开关注释\n',
|
'# 插件配置头注释\n[plugin]\nconfig_version = "1.0.0"\nenabled = false # 启用开关注释\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,7 +209,44 @@ def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path)
|
|||||||
|
|
||||||
with config_path.open("rb") as handle:
|
with config_path.open("rb") as handle:
|
||||||
saved_config = tomllib.load(handle)
|
saved_config = tomllib.load(handle)
|
||||||
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
|
assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_apply_plugin_config_same_version_does_not_rewrite_file(tmp_path: Path) -> None:
|
||||||
|
"""Runner 在配置版本未变化时不应仅因补齐默认值而重写文件。"""
|
||||||
|
|
||||||
|
plugin = _DemoConfigPlugin()
|
||||||
|
runner = PluginRunner(
|
||||||
|
host_address="ipc://unused",
|
||||||
|
session_token="session-token",
|
||||||
|
plugin_dirs=[],
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
original_config_text = '# 原始注释\n[plugin]\nconfig_version = "2.0.0"\nenabled = false\n'
|
||||||
|
config_path.write_text(original_config_text, encoding="utf-8")
|
||||||
|
|
||||||
|
runner._apply_plugin_config(cast(Any, meta))
|
||||||
|
|
||||||
|
assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
|
assert config_path.read_text(encoding="utf-8") == original_config_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_apply_plugin_config_requires_config_version(tmp_path: Path) -> None:
|
||||||
|
"""Runner 应拒绝缺少配置版本号的插件配置文件。"""
|
||||||
|
|
||||||
|
plugin = _DemoConfigPlugin()
|
||||||
|
runner = PluginRunner(
|
||||||
|
host_address="ipc://unused",
|
||||||
|
session_token="session-token",
|
||||||
|
plugin_dirs=[],
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text("[plugin]\nenabled = true\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="config_version"):
|
||||||
|
runner._apply_plugin_config(cast(Any, meta))
|
||||||
|
|
||||||
|
|
||||||
def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None:
|
def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None:
|
||||||
@@ -246,14 +299,18 @@ async def test_runner_validate_plugin_config_handler_returns_normalized_config(m
|
|||||||
message_type=MessageType.REQUEST,
|
message_type=MessageType.REQUEST,
|
||||||
method="plugin.validate_config",
|
method="plugin.validate_config",
|
||||||
plugin_id="demo.plugin",
|
plugin_id="demo.plugin",
|
||||||
payload=ValidatePluginConfigPayload(config_data={"plugin": {"enabled": False}}).model_dump(),
|
payload=ValidatePluginConfigPayload(
|
||||||
|
config_data={"plugin": {"config_version": "2.0.0", "enabled": False}}
|
||||||
|
).model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await runner._handle_validate_plugin_config(envelope)
|
response = await runner._handle_validate_plugin_config(envelope)
|
||||||
|
|
||||||
assert response.error is None
|
assert response.error is None
|
||||||
assert response.payload["success"] is True
|
assert response.payload["success"] is True
|
||||||
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
|
assert response.payload["normalized_config"] == {
|
||||||
|
"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -308,8 +365,12 @@ async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin(
|
|||||||
assert response.error is None
|
assert response.error is None
|
||||||
assert response.payload["success"] is True
|
assert response.payload["success"] is True
|
||||||
assert response.payload["enabled"] is False
|
assert response.payload["enabled"] is False
|
||||||
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
|
assert response.payload["normalized_config"] == {
|
||||||
assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}}
|
"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}
|
||||||
|
}
|
||||||
|
assert response.payload["default_config"] == {
|
||||||
|
"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}
|
||||||
|
}
|
||||||
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
|
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
|
||||||
|
|
||||||
|
|
||||||
@@ -333,7 +394,9 @@ async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_co
|
|||||||
message_type=MessageType.REQUEST,
|
message_type=MessageType.REQUEST,
|
||||||
method="plugin.validate_config",
|
method="plugin.validate_config",
|
||||||
plugin_id="demo.plugin",
|
plugin_id="demo.plugin",
|
||||||
payload=ValidatePluginConfigPayload(config_data={"plugin": {"retry_count": -1}}).model_dump(),
|
payload=ValidatePluginConfigPayload(
|
||||||
|
config_data={"plugin": {"config_version": "2.0.0", "retry_count": -1}}
|
||||||
|
).model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await runner._handle_validate_plugin_config(envelope)
|
response = await runner._handle_validate_plugin_config(envelope)
|
||||||
@@ -363,10 +426,37 @@ async def test_update_plugin_config_prefers_runtime_validation(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
assert plugin_id == "demo.plugin"
|
assert plugin_id == "demo.plugin"
|
||||||
assert config_data == {"plugin": {"enabled": False}}
|
assert config_data == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
return {"plugin": {"enabled": False, "retry_count": 3}}
|
return {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
|
|
||||||
fake_runtime_manager = SimpleNamespace(validate_plugin_config=_mock_validate_plugin_config)
|
async def _mock_inspect_plugin_config(
|
||||||
|
plugin_id: str,
|
||||||
|
config_data: Optional[Dict[str, Any]] = None,
|
||||||
|
*,
|
||||||
|
use_provided_config: bool = False,
|
||||||
|
) -> SimpleNamespace | None:
|
||||||
|
"""返回运行时配置快照。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: 插件 ID。
|
||||||
|
config_data: 可选配置。
|
||||||
|
use_provided_config: 是否使用传入配置。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SimpleNamespace | None: 运行时配置快照。
|
||||||
|
"""
|
||||||
|
|
||||||
|
del config_data, use_provided_config
|
||||||
|
if plugin_id != "demo.plugin":
|
||||||
|
return None
|
||||||
|
return SimpleNamespace(
|
||||||
|
normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}}
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_runtime_manager = SimpleNamespace(
|
||||||
|
inspect_plugin_config=_mock_inspect_plugin_config,
|
||||||
|
validate_plugin_config=_mock_validate_plugin_config,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"src.webui.routers.plugin.config_routes.require_plugin_token",
|
"src.webui.routers.plugin.config_routes.require_plugin_token",
|
||||||
@@ -390,7 +480,7 @@ async def test_update_plugin_config_prefers_runtime_validation(
|
|||||||
assert response["success"] is True
|
assert response["success"] is True
|
||||||
with config_path.open("rb") as handle:
|
with config_path.open("rb") as handle:
|
||||||
saved_config = tomllib.load(handle)
|
saved_config = tomllib.load(handle)
|
||||||
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
|
assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -432,7 +522,7 @@ async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin
|
|||||||
"sections": {"plugin": {"fields": {}}},
|
"sections": {"plugin": {"fields": {}}},
|
||||||
"layout": {"type": "auto", "tabs": []},
|
"layout": {"type": "auto", "tabs": []},
|
||||||
},
|
},
|
||||||
normalized_config={"plugin": {"enabled": True, "retry_count": 3}},
|
normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -458,6 +548,6 @@ async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin
|
|||||||
assert schema_response["schema"]["plugin_id"] == "demo.plugin"
|
assert schema_response["schema"]["plugin_id"] == "demo.plugin"
|
||||||
assert config_response == {
|
assert config_response == {
|
||||||
"success": True,
|
"success": True,
|
||||||
"config": {"plugin": {"enabled": True, "retry_count": 3}},
|
"config": {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}},
|
||||||
"message": "配置文件不存在,已返回默认配置",
|
"message": "配置文件不存在,已返回默认配置",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
|
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
|
||||||
@@ -28,6 +29,7 @@ import tomllib
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from src.common.logger import get_console_handler, get_logger, initialize_logging
|
from src.common.logger import get_console_handler, get_logger, initialize_logging
|
||||||
|
from src.config.config_utils import compare_versions
|
||||||
from src.plugin_runtime import (
|
from src.plugin_runtime import (
|
||||||
ENV_BLOCKED_PLUGIN_REASONS,
|
ENV_BLOCKED_PLUGIN_REASONS,
|
||||||
ENV_EXTERNAL_PLUGIN_IDS,
|
ENV_EXTERNAL_PLUGIN_IDS,
|
||||||
@@ -154,6 +156,122 @@ class PluginActivationStatus(str, Enum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PluginConfigNormalizationResult:
|
||||||
|
"""描述插件配置归一化结果。"""
|
||||||
|
|
||||||
|
normalized_config: Dict[str, Any]
|
||||||
|
changed: bool
|
||||||
|
should_persist: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PluginConfigVersionError(ValueError):
|
||||||
|
"""插件配置版本不合法时抛出的异常。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_copy_plugin_config_value(value: Any) -> Any:
|
||||||
|
"""递归复制插件配置值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: 待复制的任意配置值。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: 深复制后的配置值。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return _deep_copy_plugin_config_mapping(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_deep_copy_plugin_config_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_copy_plugin_config_mapping(value: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""递归复制插件配置字典。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: 待复制的插件配置映射。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 深复制后的插件配置字典。
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {str(key): _deep_copy_plugin_config_value(item) for key, item in value.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_plugin_config_fields(target: Dict[str, Any], source: Mapping[str, Any]) -> None:
|
||||||
|
"""将旧配置中的已有字段覆盖到新配置骨架中。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: 以最新默认配置构造出的目标配置字典。
|
||||||
|
source: 旧版本配置字典。
|
||||||
|
"""
|
||||||
|
|
||||||
|
for key, source_value in source.items():
|
||||||
|
if key not in target:
|
||||||
|
continue
|
||||||
|
if key == "config_version":
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_value = target[key]
|
||||||
|
if isinstance(target_value, dict) and isinstance(source_value, Mapping):
|
||||||
|
_overlay_plugin_config_fields(target_value, source_value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
target[key] = _deep_copy_plugin_config_value(source_value)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_plugin_config_version(config_data: Mapping[str, Any]) -> str:
|
||||||
|
"""提取插件配置中的版本号。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_data: 插件配置字典。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: ``plugin.config_version`` 的规范化字符串值。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PluginConfigVersionError: 当缺少 ``[plugin]`` 配置节或 ``config_version``
|
||||||
|
字段为空时抛出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
plugin_section = config_data.get("plugin")
|
||||||
|
if not isinstance(plugin_section, Mapping):
|
||||||
|
raise PluginConfigVersionError(
|
||||||
|
"插件配置文件缺少 [plugin] 配置节,且必须提供 plugin.config_version 版本号"
|
||||||
|
)
|
||||||
|
|
||||||
|
version_value = plugin_section.get("config_version")
|
||||||
|
normalized_version = str(version_value or "").strip()
|
||||||
|
if not normalized_version:
|
||||||
|
raise PluginConfigVersionError(
|
||||||
|
"插件配置文件缺少 plugin.config_version 版本号,当前版本策略不再兼容无版本配置"
|
||||||
|
)
|
||||||
|
return normalized_version
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_plugin_config_data(
|
||||||
|
default_config: Mapping[str, Any],
|
||||||
|
current_config: Mapping[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""基于默认结构重建插件配置。
|
||||||
|
|
||||||
|
该方法用于版本升级场景:以最新默认配置为骨架,仅迁移仍然存在的旧字段值,
|
||||||
|
从而达到“补齐新增字段、移除废弃字段、保留用户已有值”的效果。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_config: 最新默认配置内容。
|
||||||
|
current_config: 旧版本配置内容。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 按最新结构重建后的配置字典。
|
||||||
|
"""
|
||||||
|
|
||||||
|
rebuilt_config = _deep_copy_plugin_config_mapping(default_config)
|
||||||
|
_overlay_plugin_config_fields(rebuilt_config, current_config)
|
||||||
|
return rebuilt_config
|
||||||
|
|
||||||
|
|
||||||
def _install_shutdown_signal_handlers(
|
def _install_shutdown_signal_handlers(
|
||||||
mark_runner_shutting_down: Callable[[], None],
|
mark_runner_shutting_down: Callable[[], None],
|
||||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
@@ -414,12 +532,19 @@ class PluginRunner:
|
|||||||
Dict[str, Any]: 归一化后的当前插件配置。
|
Dict[str, Any]: 归一化后的当前插件配置。
|
||||||
"""
|
"""
|
||||||
instance = meta.instance
|
instance = meta.instance
|
||||||
raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir)
|
|
||||||
plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config)
|
|
||||||
config_path = Path(meta.plugin_dir) / "config.toml"
|
|
||||||
default_config = self._get_plugin_default_config(instance)
|
default_config = self._get_plugin_default_config(instance)
|
||||||
|
raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir, meta.plugin_id)
|
||||||
|
normalization_result = self._normalize_plugin_config(
|
||||||
|
instance,
|
||||||
|
raw_config,
|
||||||
|
default_config=default_config,
|
||||||
|
suppress_errors=False,
|
||||||
|
enforce_version=True,
|
||||||
|
)
|
||||||
|
plugin_config = normalization_result.normalized_config
|
||||||
|
config_path = Path(meta.plugin_dir) / "config.toml"
|
||||||
should_initialize_file = not config_path.exists() and bool(default_config)
|
should_initialize_file = not config_path.exists() and bool(default_config)
|
||||||
if should_persist or should_initialize_file:
|
if normalization_result.should_persist or should_initialize_file:
|
||||||
self._save_plugin_config(meta.plugin_dir, plugin_config)
|
self._save_plugin_config(meta.plugin_dir, plugin_config)
|
||||||
if hasattr(instance, "set_plugin_config"):
|
if hasattr(instance, "set_plugin_config"):
|
||||||
try:
|
try:
|
||||||
@@ -433,30 +558,105 @@ class PluginRunner:
|
|||||||
instance: object,
|
instance: object,
|
||||||
config_data: Optional[Dict[str, Any]],
|
config_data: Optional[Dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
default_config: Optional[Dict[str, Any]] = None,
|
||||||
suppress_errors: bool = True,
|
suppress_errors: bool = True,
|
||||||
) -> Tuple[Dict[str, Any], bool]:
|
enforce_version: bool = True,
|
||||||
|
) -> PluginConfigNormalizationResult:
|
||||||
"""对插件配置做统一归一化处理。
|
"""对插件配置做统一归一化处理。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance: 插件实例。
|
instance: 插件实例。
|
||||||
config_data: 原始配置数据。
|
config_data: 原始配置数据。
|
||||||
|
default_config: 插件声明的默认配置。
|
||||||
suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。
|
suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。
|
||||||
|
enforce_version: 是否强制执行 ``plugin.config_version`` 版本检查。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否需要回写文件。
|
PluginConfigNormalizationResult: 归一化结果、是否发生变更以及是否应写回文件。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
normalized_config = dict(config_data or {})
|
raw_config = dict(config_data or {})
|
||||||
if not hasattr(instance, "normalize_plugin_config"):
|
latest_default_config = default_config if default_config is not None else self._get_plugin_default_config(instance)
|
||||||
return normalized_config, False
|
config_for_normalize = rebuild_plugin_config_data(raw_config, {})
|
||||||
|
should_persist = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return cast(_ConfigAwarePlugin, instance).normalize_plugin_config(normalized_config)
|
if latest_default_config:
|
||||||
|
if enforce_version:
|
||||||
|
config_for_normalize, should_persist = self._prepare_plugin_config_for_version_update(
|
||||||
|
raw_config=raw_config,
|
||||||
|
default_config=latest_default_config,
|
||||||
|
)
|
||||||
|
elif not raw_config:
|
||||||
|
config_for_normalize = rebuild_plugin_config_data(latest_default_config, {})
|
||||||
|
except Exception as exc:
|
||||||
|
if not suppress_errors:
|
||||||
|
raise
|
||||||
|
logger.warning(f"插件配置版本检查失败,将回退为原始配置: {exc}")
|
||||||
|
return PluginConfigNormalizationResult(
|
||||||
|
normalized_config=raw_config,
|
||||||
|
changed=False,
|
||||||
|
should_persist=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(instance, "normalize_plugin_config"):
|
||||||
|
return PluginConfigNormalizationResult(
|
||||||
|
normalized_config=config_for_normalize,
|
||||||
|
changed=config_for_normalize != raw_config,
|
||||||
|
should_persist=should_persist,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_config, normalized_changed = cast(_ConfigAwarePlugin, instance).normalize_plugin_config(
|
||||||
|
config_for_normalize
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not suppress_errors:
|
if not suppress_errors:
|
||||||
raise
|
raise
|
||||||
logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}")
|
logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}")
|
||||||
return normalized_config, False
|
return PluginConfigNormalizationResult(
|
||||||
|
normalized_config=raw_config,
|
||||||
|
changed=False,
|
||||||
|
should_persist=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PluginConfigNormalizationResult(
|
||||||
|
normalized_config=normalized_config,
|
||||||
|
changed=normalized_changed or normalized_config != raw_config,
|
||||||
|
should_persist=should_persist,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepare_plugin_config_for_version_update(
|
||||||
|
raw_config: Mapping[str, Any],
|
||||||
|
default_config: Mapping[str, Any],
|
||||||
|
) -> Tuple[Dict[str, Any], bool]:
|
||||||
|
"""基于配置版本决定是否需要重建插件配置。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_config: 当前磁盘上的插件配置。
|
||||||
|
default_config: 插件最新默认配置。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[Dict[str, Any], bool]: 用于后续归一化的配置副本,以及是否需要写回文件。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PluginConfigVersionError: 当默认配置或当前配置缺少版本号时抛出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not default_config:
|
||||||
|
return rebuild_plugin_config_data(raw_config, {}), False
|
||||||
|
|
||||||
|
latest_version = extract_plugin_config_version(default_config)
|
||||||
|
if not raw_config:
|
||||||
|
return rebuild_plugin_config_data(default_config, {}), False
|
||||||
|
|
||||||
|
current_version = extract_plugin_config_version(raw_config)
|
||||||
|
if compare_versions(current_version, latest_version):
|
||||||
|
logger.info(f"检测到插件配置版本升级: {current_version} -> {latest_version}")
|
||||||
|
return rebuild_plugin_config_data(default_config, raw_config), True
|
||||||
|
|
||||||
|
return rebuild_plugin_config_data(raw_config, {}), False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_plugin_config_document(target: Any, source: Any) -> None:
|
def _merge_plugin_config_document(target: Any, source: Any) -> None:
|
||||||
@@ -642,6 +842,7 @@ class PluginRunner:
|
|||||||
config_data: Optional[Dict[str, Any]] = None,
|
config_data: Optional[Dict[str, Any]] = None,
|
||||||
use_provided_config: bool = False,
|
use_provided_config: bool = False,
|
||||||
suppress_errors: bool = True,
|
suppress_errors: bool = True,
|
||||||
|
enforce_version: bool = True,
|
||||||
) -> InspectPluginConfigResultPayload:
|
) -> InspectPluginConfigResultPayload:
|
||||||
"""解析插件代码定义的配置元数据。
|
"""解析插件代码定义的配置元数据。
|
||||||
|
|
||||||
@@ -650,6 +851,7 @@ class PluginRunner:
|
|||||||
config_data: 可选的配置内容。
|
config_data: 可选的配置内容。
|
||||||
use_provided_config: 是否优先使用传入的配置内容。
|
use_provided_config: 是否优先使用传入的配置内容。
|
||||||
suppress_errors: 是否在归一化失败时回退原始配置。
|
suppress_errors: 是否在归一化失败时回退原始配置。
|
||||||
|
enforce_version: 是否强制校验 ``plugin.config_version``。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
InspectPluginConfigResultPayload: 结构化解析结果。
|
InspectPluginConfigResultPayload: 结构化解析结果。
|
||||||
@@ -659,14 +861,18 @@ class PluginRunner:
|
|||||||
if use_provided_config and config_data is None:
|
if use_provided_config and config_data is None:
|
||||||
raw_config = {}
|
raw_config = {}
|
||||||
|
|
||||||
normalized_config, changed = self._normalize_plugin_config(
|
default_config = self._get_plugin_default_config(meta.instance)
|
||||||
|
normalization_result = self._normalize_plugin_config(
|
||||||
meta.instance,
|
meta.instance,
|
||||||
raw_config,
|
raw_config,
|
||||||
|
default_config=default_config,
|
||||||
suppress_errors=suppress_errors,
|
suppress_errors=suppress_errors,
|
||||||
|
enforce_version=enforce_version,
|
||||||
)
|
)
|
||||||
default_config = self._get_plugin_default_config(meta.instance)
|
normalized_config = normalization_result.normalized_config
|
||||||
|
changed = normalization_result.changed
|
||||||
if not normalized_config and not raw_config and default_config:
|
if not normalized_config and not raw_config and default_config:
|
||||||
normalized_config = dict(default_config)
|
normalized_config = rebuild_plugin_config_data(default_config, {})
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
return InspectPluginConfigResultPayload(
|
return InspectPluginConfigResultPayload(
|
||||||
@@ -946,7 +1152,16 @@ class PluginRunner:
|
|||||||
PluginActivationStatus: 插件激活结果。
|
PluginActivationStatus: 插件激活结果。
|
||||||
"""
|
"""
|
||||||
self._inject_context(meta.plugin_id, meta.instance)
|
self._inject_context(meta.plugin_id, meta.instance)
|
||||||
plugin_config = self._apply_plugin_config(meta)
|
try:
|
||||||
|
plugin_config = self._apply_plugin_config(meta)
|
||||||
|
except PluginConfigVersionError as exc:
|
||||||
|
logger.error(f"插件 {meta.plugin_id} 配置版本非法: {exc}")
|
||||||
|
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
|
||||||
|
return PluginActivationStatus.FAILED
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"插件 {meta.plugin_id} 配置加载失败: {exc}", exc_info=True)
|
||||||
|
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
|
||||||
|
return PluginActivationStatus.FAILED
|
||||||
if not self._is_plugin_enabled(plugin_config):
|
if not self._is_plugin_enabled(plugin_config):
|
||||||
logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活")
|
logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活")
|
||||||
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
|
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
|
||||||
@@ -1604,7 +1819,8 @@ class PluginRunner:
|
|||||||
meta,
|
meta,
|
||||||
config_data=payload.config_data,
|
config_data=payload.config_data,
|
||||||
use_provided_config=payload.use_provided_config,
|
use_provided_config=payload.use_provided_config,
|
||||||
suppress_errors=True,
|
suppress_errors=payload.use_provided_config,
|
||||||
|
enforce_version=not payload.use_provided_config,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
|
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
|
||||||
@@ -1643,6 +1859,7 @@ class PluginRunner:
|
|||||||
config_data=payload.config_data,
|
config_data=payload.config_data,
|
||||||
use_provided_config=True,
|
use_provided_config=True,
|
||||||
suppress_errors=False,
|
suppress_errors=False,
|
||||||
|
enforce_version=True,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
|
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, cast
|
from typing import Any, Dict, Optional, cast
|
||||||
|
|
||||||
import tomlkit
|
|
||||||
from fastapi import APIRouter, Cookie, HTTPException
|
from fastapi import APIRouter, Cookie, HTTPException
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
from src.common.logger import get_logger
|
from src.common.logger import get_logger
|
||||||
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
|
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
|
||||||
@@ -13,6 +13,7 @@ from src.webui.utils.toml_utils import save_toml_with_format
|
|||||||
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
|
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
|
||||||
from .support import (
|
from .support import (
|
||||||
backup_file,
|
backup_file,
|
||||||
|
deep_merge,
|
||||||
find_plugin_path_by_id,
|
find_plugin_path_by_id,
|
||||||
get_plugin_config_path,
|
get_plugin_config_path,
|
||||||
normalize_dotted_keys,
|
normalize_dotted_keys,
|
||||||
@@ -26,6 +27,15 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
def _to_builtin_data(obj: Any) -> Any:
|
def _to_builtin_data(obj: Any) -> Any:
|
||||||
|
"""将 TOML 对象递归转换为内建 Python 数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: 原始对象。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: 转换后的内建数据结构。
|
||||||
|
"""
|
||||||
|
|
||||||
if hasattr(obj, "unwrap"):
|
if hasattr(obj, "unwrap"):
|
||||||
try:
|
try:
|
||||||
obj = obj.unwrap()
|
obj = obj.unwrap()
|
||||||
@@ -39,6 +49,22 @@ def _to_builtin_data(obj: Any) -> Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_plugin_config_patch(base_config: Dict[str, Any], patch_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""以现有配置为基线合并本次插件配置改动。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_config: 当前完整配置。
|
||||||
|
patch_config: 本次提交的局部配置改动。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 合并后的完整配置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged_config = cast(Dict[str, Any], _to_builtin_data(base_config))
|
||||||
|
deep_merge(merged_config, patch_config)
|
||||||
|
return merged_config
|
||||||
|
|
||||||
|
|
||||||
def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]:
|
def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]:
|
||||||
"""根据当前配置内容自动推断一个兜底 Schema。
|
"""根据当前配置内容自动推断一个兜底 Schema。
|
||||||
|
|
||||||
@@ -489,7 +515,19 @@ async def update_plugin_config(
|
|||||||
|
|
||||||
config_data = request.config or {}
|
config_data = request.config or {}
|
||||||
if isinstance(config_data, dict):
|
if isinstance(config_data, dict):
|
||||||
config_data = normalize_dotted_keys(config_data)
|
config_patch = normalize_dotted_keys(config_data)
|
||||||
|
runtime_snapshot = None
|
||||||
|
try:
|
||||||
|
runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(f"插件 {plugin_id} 保存前配置检查失败,将回退到磁盘内容: {exc}")
|
||||||
|
|
||||||
|
base_config = (
|
||||||
|
dict(runtime_snapshot.normalized_config)
|
||||||
|
if runtime_snapshot is not None
|
||||||
|
else _load_plugin_config_from_disk(plugin_path)
|
||||||
|
)
|
||||||
|
config_data = _merge_plugin_config_patch(base_config, config_patch)
|
||||||
runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data)
|
runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data)
|
||||||
if isinstance(runtime_validated_config, dict):
|
if isinstance(runtime_validated_config, dict):
|
||||||
config_data = runtime_validated_config
|
config_data = runtime_validated_config
|
||||||
|
|||||||
Reference in New Issue
Block a user