ForcePilot/backend/package/yuxi/channel/plugins/install.py
Kris dd1857221c feat(channel/plugins): 新增渠道插件系统完整实现
实现了渠道插件的全生命周期管理能力,包括:
1. 插件清单的加载、解析与存储
2. 依赖校验与拓扑排序安装/卸载
3. 插件发现与安全沙箱执行环境
4. 插件注册中心与目录管理能力
2026-05-21 10:27:58 +08:00

169 lines
5.6 KiB
Python

from __future__ import annotations
import logging
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from .dependency_validator import DependencyValidator
from .discovery import ChannelPluginDiscovery
from .manifest import ChannelPluginManifest
logger = logging.getLogger(__name__)
class SecurityError(Exception):
"""安全校验错误 —— 阻断不安全的插件安装。"""
def install_channel_plugin(
manifest: ChannelPluginManifest,
validator: DependencyValidator | None = None,
) -> None:
"""安装一个渠道插件:
1. 安装依赖 (pip install) —— 带白名单校验
2. 复制/链接插件代码到目标目录
3. 发现并注册插件类
Args:
manifest: 插件清单
validator: 依赖校验器,为 None 时使用默认校验器
"""
logger.info("Installing channel plugin: %s", manifest.id)
if manifest.dependencies:
logger.info("Installing dependencies for %s: %s", manifest.id, manifest.dependencies)
_install_dependencies_with_validation(manifest.dependencies, validator)
if manifest.source_path:
try:
source = ChannelPluginDiscovery.validate_plugin_path(manifest.source_path, purpose="source copy")
except PermissionError as e:
raise SecurityError(str(e)) from e
target = Path(manifest.target_path or f"yuxi/channels/{manifest.id}")
if source.exists():
if target.exists():
shutil.rmtree(target)
if source.is_dir():
shutil.copytree(source, target)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
logger.info("Copied plugin source from %s to %s", source, target)
if manifest.entry_point:
try:
entry_path = ChannelPluginDiscovery.validate_plugin_path(manifest.entry_point, purpose="entry point")
except PermissionError as e:
raise SecurityError(str(e)) from e
ChannelPluginDiscovery.discover_from_path(entry_path)
logger.info("Channel plugin '%s' installed successfully", manifest.id)
def _install_dependencies_with_validation(
dependencies: list[str],
validator: DependencyValidator | None = None,
) -> None:
"""带白名单校验的依赖安装。
校验流程:
1. 使用 DependencyValidator 校验每个依赖
2. 收集所有校验错误
3. 如有错误,抛出 SecurityError 阻断安装
4. 校验通过后执行 pip install
"""
validator = validator or DependencyValidator.default_validator()
results = validator.validate_all(dependencies)
errors = [err for r in results for err in r.errors]
if errors:
for err in errors:
logger.error("Dependency validation failed: %s", err)
raise SecurityError(f"Dependency validation failed: {'; '.join(errors)}")
allowed_deps = [r.requested_spec for r in results if r.is_valid]
if not allowed_deps:
logger.info("No valid dependencies to install after validation")
return
logger.info("Installing validated dependencies: %s", allowed_deps)
try:
pip_cmd = [
sys.executable, "-m", "pip", "install",
"--no-build-isolation",
*allowed_deps,
]
subprocess.check_output(
pip_cmd,
stderr=subprocess.STDOUT,
text=True,
)
except subprocess.CalledProcessError as e:
logger.error("pip install failed: %s", e.output)
raise SecurityError(f"Failed to install dependencies: {e.output}") from e
def uninstall_channel_plugin(
plugin_id: str,
target_dir: str | Path | None = None,
dependencies: list[str] | None = None,
) -> bool:
"""卸载一个渠道插件:
1. 从 Registry 中注销
2. 清理文件系统中的插件文件
3. 卸载 pip 安装的依赖(尽力而为,不阻断卸载流程)
Args:
plugin_id: 插件 ID
target_dir: 插件文件所在目录(从 manifest.target_path 提取)
dependencies: 插件依赖列表(从 manifest.dependencies 提取)
"""
from .registry import ChannelPluginRegistry
ChannelPluginRegistry.unregister(plugin_id)
if target_dir:
target = Path(target_dir)
if target.exists():
shutil.rmtree(target)
logger.info("Removed plugin directory: %s", target)
if dependencies:
_uninstall_dependencies(plugin_id, dependencies)
logger.info("Channel plugin '%s' fully uninstalled", plugin_id)
return True
def _uninstall_dependencies(plugin_id: str, dependencies: list[str]) -> None:
"""卸载插件依赖(尽力而为:失败仅记录告警,不抛异常)。"""
if not dependencies:
return
package_names = []
for dep in dependencies:
name = dep.strip().split("=")[0].split(">")[0].split("<")[0].split("~")[0].split("!")[0].strip()
name = name.split("[")[0].split(";")[0].strip()
if name:
package_names.append(name)
if not package_names:
return
logger.info("Uninstalling dependencies for '%s': %s", plugin_id, package_names)
try:
subprocess.check_output(
[sys.executable, "-m", "pip", "uninstall", "-y", *package_names],
stderr=subprocess.STDOUT,
text=True,
)
logger.info("Successfully uninstalled dependencies for '%s'", plugin_id)
except subprocess.CalledProcessError as e:
logger.warning(
"Failed to uninstall dependencies for '%s': %s (plugin already unregistered)",
plugin_id, e.output.strip() if e.output else str(e),
)