实现了渠道插件的全生命周期管理能力,包括: 1. 插件清单的加载、解析与存储 2. 依赖校验与拓扑排序安装/卸载 3. 插件发现与安全沙箱执行环境 4. 插件注册中心与目录管理能力
169 lines
5.6 KiB
Python
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),
|
|
) |