479 lines
15 KiB
Rust
479 lines
15 KiB
Rust
//! 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(®istry_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(®istry_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(®istry).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);
|
||
}
|
||
}
|