Files
ShowenV2/src/core/plugin_loader.rs

479 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! PluginLoader — 扫描 plugin_store/ 目录,发现并加载动态插件
//!
//! 目录结构:
//! ```text
//! plugin_store/
//! ├── registry.json
//! └── custom-sensor/
//! ├── 1.0.0/
//! │ ├── manifest.json
//! │ └── libcustom_sensor.so
//! └── 1.1.0/
//! ├── manifest.json
//! └── libcustom_sensor.so
//! ```
use crate::core::dynamic_plugin::DynamicPlugin;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// 插件清单 (manifest.json)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub version: String,
pub sdk_version: String,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default = "default_error_policy")]
pub error_policy: ErrorPolicy,
pub so_filename: String,
/// 插件声明支持的功能列表
#[serde(default)]
pub capabilities: Vec<String>,
/// 挂载时必须通过测试的功能capabilities 的子集)
#[serde(default)]
pub required_capabilities: Vec<String>,
/// 自测超时(毫秒),默认 5000
#[serde(default = "default_test_timeout")]
pub test_timeout_ms: u64,
/// 是否在挂载时自动运行自测,默认 true
#[serde(default = "default_true")]
pub auto_test: bool,
}
fn default_error_policy() -> ErrorPolicy {
ErrorPolicy::AutoRollback
}
fn default_test_timeout() -> u64 {
5000
}
/// 插件错误处理策略
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorPolicy {
/// 自动回退到上一个稳定版本
AutoRollback,
/// 禁用插件并记录日志
DisableAndLog,
}
/// 全局注册表 (registry.json)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginRegistry {
#[serde(default)]
pub plugins: std::collections::HashMap<String, PluginRegistryEntry>,
}
/// 注册表中每个插件的条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRegistryEntry {
pub active_version: String,
#[serde(default)]
pub last_stable_version: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_error_policy")]
pub error_policy: ErrorPolicy,
#[serde(default = "default_max_errors")]
pub max_errors: u32,
}
fn default_true() -> bool {
true
}
fn default_max_errors() -> u32 {
5
}
/// 插件加载器
pub struct PluginLoader {
store_path: PathBuf,
}
impl PluginLoader {
pub fn new(store_path: impl Into<PathBuf>) -> Self {
Self {
store_path: store_path.into(),
}
}
/// 获取存储路径
pub fn store_path(&self) -> &Path {
&self.store_path
}
/// 读取全局注册表
pub fn load_registry(&self) -> Result<PluginRegistry> {
let registry_path = self.store_path.join("registry.json");
if !registry_path.exists() {
return Ok(PluginRegistry::default());
}
let content = std::fs::read_to_string(&registry_path)
.with_context(|| format!("failed to read {}", registry_path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", registry_path.display()))
}
/// 保存全局注册表
pub fn save_registry(&self, registry: &PluginRegistry) -> Result<()> {
let registry_path = self.store_path.join("registry.json");
let content =
serde_json::to_string_pretty(registry).context("failed to serialize registry")?;
std::fs::write(&registry_path, content)
.with_context(|| format!("failed to write {}", registry_path.display()))
}
/// 发现所有可用插件(扫描目录)
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
let mut manifests = Vec::new();
if !self.store_path.exists() {
return Ok(manifests);
}
for entry in std::fs::read_dir(&self.store_path)
.with_context(|| format!("failed to read {}", self.store_path.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
// Skip non-plugin entries (e.g. registry.json)
match path.file_name().and_then(|n| n.to_str()) {
Some(name) if name != "registry.json" => {}
_ => continue,
}
// 扫描版本子目录
for version_entry in std::fs::read_dir(&path)? {
let version_entry = version_entry?;
let version_path = version_entry.path();
if !version_path.is_dir() {
continue;
}
let manifest_path = version_path.join("manifest.json");
if manifest_path.exists() {
match self.read_manifest(&manifest_path) {
Ok(manifest) => manifests.push(manifest),
Err(e) => {
eprintln!(
"[PluginLoader] 跳过无效清单 {}: {e}",
manifest_path.display()
);
}
}
}
}
}
Ok(manifests)
}
/// 读取插件清单
fn read_manifest(&self, path: &Path) -> Result<PluginManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))
}
/// 加载指定插件
/// version: None 表示使用注册表中的 active_version
pub fn load_plugin(
&self,
plugin_id: &str,
version: Option<&str>,
) -> Result<(DynamicPlugin, PluginManifest)> {
let version = match version {
Some(v) => v.to_string(),
None => {
let registry = self.load_registry()?;
registry
.plugins
.get(plugin_id)
.map(|e| e.active_version.clone())
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found in registry"))?
}
};
let version_dir = self.store_path.join(plugin_id).join(&version);
let manifest_path = version_dir.join("manifest.json");
let manifest = self.read_manifest(&manifest_path)?;
if manifest.id != plugin_id {
return Err(anyhow!(
"plugin manifest id mismatch: requested '{plugin_id}', found '{}'",
manifest.id
));
}
if manifest.version != version {
return Err(anyhow!(
"plugin manifest version mismatch: requested '{version}', found '{}'",
manifest.version
));
}
let so_path = version_dir.join(&manifest.so_filename);
if !so_path.exists() {
return Err(anyhow!("plugin .so not found: {}", so_path.display()));
}
let so_path_str = so_path.to_string_lossy().to_string();
let mut plugin =
unsafe { DynamicPlugin::load(&so_path_str, manifest.dependencies.clone())? };
plugin.set_id(manifest.id.clone());
Ok((plugin, manifest))
}
/// 列出插件的所有已安装版本
pub fn list_versions(&self, plugin_id: &str) -> Result<Vec<String>> {
let plugin_dir = self.store_path.join(plugin_id);
if !plugin_dir.exists() {
return Ok(vec![]);
}
let mut versions = Vec::new();
for entry in std::fs::read_dir(&plugin_dir)? {
let entry = entry?;
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
versions.push(name.to_string());
}
}
}
versions.sort();
Ok(versions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("showen_plugin_loader_{name}_{nanos}"))
}
fn setup_test_store(base: &Path) {
let plugin_dir = base.join("test-plugin").join("1.0.0");
fs::create_dir_all(&plugin_dir).unwrap();
let manifest = PluginManifest {
id: "test-plugin".to_string(),
version: "1.0.0".to_string(),
sdk_version: "0.2.0".to_string(),
dependencies: vec![],
error_policy: ErrorPolicy::AutoRollback,
so_filename: "libtest_plugin.so".to_string(),
capabilities: vec![],
required_capabilities: vec![],
test_timeout_ms: 5000,
auto_test: true,
};
fs::write(
plugin_dir.join("manifest.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
}
#[test]
fn discover_plugins_finds_manifests() {
let tmp = std::env::temp_dir().join("showen_test_discover");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
setup_test_store(&tmp);
let loader = PluginLoader::new(&tmp);
let manifests = loader.discover_plugins().unwrap();
assert_eq!(manifests.len(), 1);
assert_eq!(manifests[0].id, "test-plugin");
assert_eq!(manifests[0].version, "1.0.0");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn empty_store_returns_no_plugins() {
let tmp = std::env::temp_dir().join("showen_test_empty");
let _ = fs::remove_dir_all(&tmp);
let loader = PluginLoader::new(&tmp);
let manifests = loader.discover_plugins().unwrap();
assert!(manifests.is_empty());
}
#[test]
fn registry_round_trip() {
let tmp = std::env::temp_dir().join("showen_test_registry");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
let loader = PluginLoader::new(&tmp);
let mut registry = PluginRegistry::default();
registry.plugins.insert(
"test-plugin".to_string(),
PluginRegistryEntry {
active_version: "1.0.0".to_string(),
last_stable_version: None,
enabled: true,
error_policy: ErrorPolicy::AutoRollback,
max_errors: 5,
},
);
loader.save_registry(&registry).unwrap();
let loaded = loader.load_registry().unwrap();
assert_eq!(loaded.plugins.len(), 1);
let entry = &loaded.plugins["test-plugin"];
assert_eq!(entry.active_version, "1.0.0");
assert!(entry.enabled);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn list_versions_returns_sorted() {
let tmp = std::env::temp_dir().join("showen_test_versions");
let _ = fs::remove_dir_all(&tmp);
let plugin_dir = tmp.join("my-plugin");
fs::create_dir_all(plugin_dir.join("1.1.0")).unwrap();
fs::create_dir_all(plugin_dir.join("1.0.0")).unwrap();
fs::create_dir_all(plugin_dir.join("2.0.0")).unwrap();
let loader = PluginLoader::new(&tmp);
let versions = loader.list_versions("my-plugin").unwrap();
assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn manifest_parses_capabilities() {
let json = r#"{
"id": "sensor",
"version": "1.0.0",
"sdk_version": "0.2.0",
"so_filename": "libsensor.so",
"capabilities": ["temperature", "humidity"],
"required_capabilities": ["temperature"],
"test_timeout_ms": 3000,
"auto_test": true
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.capabilities, vec!["temperature", "humidity"]);
assert_eq!(manifest.required_capabilities, vec!["temperature"]);
assert_eq!(manifest.test_timeout_ms, 3000);
assert!(manifest.auto_test);
}
#[test]
fn manifest_capabilities_default_empty() {
let json = r#"{
"id": "basic",
"version": "1.0.0",
"sdk_version": "0.2.0",
"so_filename": "libbasic.so"
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.capabilities.is_empty());
assert!(manifest.required_capabilities.is_empty());
assert_eq!(manifest.test_timeout_ms, 5000);
assert!(manifest.auto_test);
}
#[test]
fn manifest_test_timeout_ms_is_configurable_from_manifest() {
let json = r#"{
"id": "timed-plugin",
"version": "1.0.0",
"sdk_version": "0.2.0",
"so_filename": "libtimed_plugin.so",
"test_timeout_ms": 12000
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.test_timeout_ms, 12000);
}
#[test]
fn load_plugin_rejects_manifest_id_mismatch() {
let tmp = unique_test_dir("id_mismatch");
let plugin_dir = tmp.join("expected-plugin").join("1.0.0");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("manifest.json"),
r#"{
"id": "other-plugin",
"version": "1.0.0",
"sdk_version": "0.2.0",
"so_filename": "libexpected_plugin.so"
}"#,
)
.unwrap();
let loader = PluginLoader::new(&tmp);
let error = match loader.load_plugin("expected-plugin", Some("1.0.0")) {
Ok(_) => panic!("id mismatch should be rejected"),
Err(error) => error,
};
assert!(error.to_string().contains(
"plugin manifest id mismatch: requested 'expected-plugin', found 'other-plugin'"
));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn load_plugin_rejects_manifest_version_mismatch() {
let tmp = unique_test_dir("version_mismatch");
let plugin_dir = tmp.join("expected-plugin").join("1.0.0");
fs::create_dir_all(&plugin_dir).unwrap();
fs::write(
plugin_dir.join("manifest.json"),
r#"{
"id": "expected-plugin",
"version": "2.0.0",
"sdk_version": "0.2.0",
"so_filename": "libexpected_plugin.so"
}"#,
)
.unwrap();
let loader = PluginLoader::new(&tmp);
let error = match loader.load_plugin("expected-plugin", Some("1.0.0")) {
Ok(_) => panic!("version mismatch should be rejected"),
Err(error) => error,
};
assert!(error
.to_string()
.contains("plugin manifest version mismatch: requested '1.0.0', found '2.0.0'"));
let _ = fs::remove_dir_all(&tmp);
}
}