various: simplify code; work on security and performance
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9a5114addcab5fbff430ab2b919b83466a6a6964
This commit is contained in:
parent
016841b200
commit
c4adc4e3e0
75 changed files with 12921 additions and 358 deletions
419
crates/pinakes-core/src/plugin/mod.rs
Normal file
419
crates/pinakes-core/src/plugin/mod.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
//! Plugin system for Pinakes
|
||||
//!
|
||||
//! This module provides a comprehensive plugin architecture that allows extending
|
||||
//! Pinakes with custom media types, metadata extractors, search backends, and more.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! - Plugins are compiled to WASM and run in a sandboxed environment
|
||||
//! - Capability-based security controls what plugins can access
|
||||
//! - Hot-reload support for development
|
||||
//! - Automatic plugin discovery from configured directories
|
||||
|
||||
use anyhow::Result;
|
||||
use pinakes_plugin_api::{PluginContext, PluginMetadata};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
pub mod loader;
|
||||
pub mod registry;
|
||||
pub mod runtime;
|
||||
pub mod security;
|
||||
|
||||
pub use loader::PluginLoader;
|
||||
pub use registry::{PluginRegistry, RegisteredPlugin};
|
||||
pub use runtime::{WasmPlugin, WasmRuntime};
|
||||
pub use security::CapabilityEnforcer;
|
||||
|
||||
/// Plugin manager coordinates plugin lifecycle and operations
|
||||
pub struct PluginManager {
|
||||
/// Plugin registry
|
||||
registry: Arc<RwLock<PluginRegistry>>,
|
||||
|
||||
/// WASM runtime for executing plugins
|
||||
runtime: Arc<WasmRuntime>,
|
||||
|
||||
/// Plugin loader for discovery and loading
|
||||
loader: PluginLoader,
|
||||
|
||||
/// Capability enforcer for security
|
||||
enforcer: CapabilityEnforcer,
|
||||
|
||||
/// Plugin data directory
|
||||
data_dir: PathBuf,
|
||||
|
||||
/// Plugin cache directory
|
||||
cache_dir: PathBuf,
|
||||
|
||||
/// Configuration
|
||||
config: PluginManagerConfig,
|
||||
}
|
||||
|
||||
/// Configuration for the plugin manager
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginManagerConfig {
|
||||
/// Directories to search for plugins
|
||||
pub plugin_dirs: Vec<PathBuf>,
|
||||
|
||||
/// Whether to enable hot-reload (for development)
|
||||
pub enable_hot_reload: bool,
|
||||
|
||||
/// Whether to allow unsigned plugins
|
||||
pub allow_unsigned: bool,
|
||||
|
||||
/// Maximum number of concurrent plugin operations
|
||||
pub max_concurrent_ops: usize,
|
||||
|
||||
/// Plugin timeout in seconds
|
||||
pub plugin_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for PluginManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_dirs: vec![],
|
||||
enable_hot_reload: false,
|
||||
allow_unsigned: false,
|
||||
max_concurrent_ops: 4,
|
||||
plugin_timeout_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::config::PluginsConfig> for PluginManagerConfig {
|
||||
fn from(cfg: crate::config::PluginsConfig) -> Self {
|
||||
Self {
|
||||
plugin_dirs: cfg.plugin_dirs,
|
||||
enable_hot_reload: cfg.enable_hot_reload,
|
||||
allow_unsigned: cfg.allow_unsigned,
|
||||
max_concurrent_ops: cfg.max_concurrent_ops,
|
||||
plugin_timeout_secs: cfg.plugin_timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Create a new plugin manager
|
||||
pub fn new(data_dir: PathBuf, cache_dir: PathBuf, config: PluginManagerConfig) -> Result<Self> {
|
||||
// Ensure directories exist
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
|
||||
let runtime = Arc::new(WasmRuntime::new()?);
|
||||
let registry = Arc::new(RwLock::new(PluginRegistry::new()));
|
||||
let loader = PluginLoader::new(config.plugin_dirs.clone());
|
||||
let enforcer = CapabilityEnforcer::new();
|
||||
|
||||
Ok(Self {
|
||||
registry,
|
||||
runtime,
|
||||
loader,
|
||||
enforcer,
|
||||
data_dir,
|
||||
cache_dir,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover and load all plugins from configured directories
|
||||
pub async fn discover_and_load_all(&self) -> Result<Vec<String>> {
|
||||
info!("Discovering plugins from {:?}", self.config.plugin_dirs);
|
||||
|
||||
let manifests = self.loader.discover_plugins().await?;
|
||||
let mut loaded_plugins = Vec::new();
|
||||
|
||||
for manifest in manifests {
|
||||
match self.load_plugin_from_manifest(&manifest).await {
|
||||
Ok(plugin_id) => {
|
||||
info!("Loaded plugin: {}", plugin_id);
|
||||
loaded_plugins.push(plugin_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load plugin {}: {}", manifest.plugin.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(loaded_plugins)
|
||||
}
|
||||
|
||||
/// Load a plugin from a manifest file
|
||||
async fn load_plugin_from_manifest(
|
||||
&self,
|
||||
manifest: &pinakes_plugin_api::PluginManifest,
|
||||
) -> Result<String> {
|
||||
let plugin_id = manifest.plugin_id();
|
||||
|
||||
// Validate plugin_id to prevent path traversal
|
||||
if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") {
|
||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
||||
}
|
||||
|
||||
// Check if already loaded
|
||||
{
|
||||
let registry = self.registry.read().await;
|
||||
if registry.is_loaded(&plugin_id) {
|
||||
return Ok(plugin_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate capabilities
|
||||
let capabilities = manifest.to_capabilities();
|
||||
self.enforcer.validate_capabilities(&capabilities)?;
|
||||
|
||||
// Create plugin context
|
||||
let plugin_data_dir = self.data_dir.join(&plugin_id);
|
||||
let plugin_cache_dir = self.cache_dir.join(&plugin_id);
|
||||
tokio::fs::create_dir_all(&plugin_data_dir).await?;
|
||||
tokio::fs::create_dir_all(&plugin_cache_dir).await?;
|
||||
|
||||
let context = PluginContext {
|
||||
data_dir: plugin_data_dir,
|
||||
cache_dir: plugin_cache_dir,
|
||||
config: manifest
|
||||
.config
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
serde_json::to_value(v).unwrap_or_else(|e| {
|
||||
tracing::warn!("failed to serialize config value for key {}: {}", k, e);
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
capabilities: capabilities.clone(),
|
||||
};
|
||||
|
||||
// Load WASM binary
|
||||
let wasm_path = self.loader.resolve_wasm_path(manifest)?;
|
||||
let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?;
|
||||
|
||||
// Initialize plugin
|
||||
let init_succeeded = match wasm_plugin.call_function("initialize", &[]).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Register plugin
|
||||
let metadata = PluginMetadata {
|
||||
id: plugin_id.clone(),
|
||||
name: manifest.plugin.name.clone(),
|
||||
version: manifest.plugin.version.clone(),
|
||||
author: manifest.plugin.author.clone().unwrap_or_default(),
|
||||
description: manifest.plugin.description.clone().unwrap_or_default(),
|
||||
api_version: manifest.plugin.api_version.clone(),
|
||||
capabilities_required: capabilities,
|
||||
};
|
||||
|
||||
// Derive manifest_path from the loader's plugin directories
|
||||
let manifest_path = self
|
||||
.loader
|
||||
.get_plugin_dir(&manifest.plugin.name)
|
||||
.map(|dir| dir.join("plugin.toml"));
|
||||
|
||||
let registered = RegisteredPlugin {
|
||||
id: plugin_id.clone(),
|
||||
metadata,
|
||||
wasm_plugin,
|
||||
manifest: manifest.clone(),
|
||||
manifest_path,
|
||||
enabled: init_succeeded,
|
||||
};
|
||||
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.register(registered)?;
|
||||
|
||||
Ok(plugin_id)
|
||||
}
|
||||
|
||||
/// Install a plugin from a file or URL
|
||||
pub async fn install_plugin(&self, source: &str) -> Result<String> {
|
||||
info!("Installing plugin from: {}", source);
|
||||
|
||||
// Download/copy plugin to plugins directory
|
||||
let plugin_path = if source.starts_with("http://") || source.starts_with("https://") {
|
||||
// Download from URL
|
||||
self.loader.download_plugin(source).await?
|
||||
} else {
|
||||
// Copy from local file
|
||||
PathBuf::from(source)
|
||||
};
|
||||
|
||||
// Load the manifest
|
||||
let manifest_path = plugin_path.join("plugin.toml");
|
||||
let manifest = pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?;
|
||||
|
||||
// Load the plugin
|
||||
self.load_plugin_from_manifest(&manifest).await
|
||||
}
|
||||
|
||||
/// Uninstall a plugin
|
||||
pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
// Validate plugin_id to prevent path traversal
|
||||
if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") {
|
||||
return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id));
|
||||
}
|
||||
|
||||
info!("Uninstalling plugin: {}", plugin_id);
|
||||
|
||||
// Shutdown plugin first
|
||||
self.shutdown_plugin(plugin_id).await?;
|
||||
|
||||
// Remove from registry
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(plugin_id)?;
|
||||
|
||||
// Remove plugin data and cache
|
||||
let plugin_data_dir = self.data_dir.join(plugin_id);
|
||||
let plugin_cache_dir = self.cache_dir.join(plugin_id);
|
||||
|
||||
if plugin_data_dir.exists() {
|
||||
std::fs::remove_dir_all(&plugin_data_dir)?;
|
||||
}
|
||||
if plugin_cache_dir.exists() {
|
||||
std::fs::remove_dir_all(&plugin_cache_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable a plugin
|
||||
pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.enable(plugin_id)
|
||||
}
|
||||
|
||||
/// Disable a plugin
|
||||
pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.disable(plugin_id)
|
||||
}
|
||||
|
||||
/// Shutdown a specific plugin
|
||||
pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
debug!("Shutting down plugin: {}", plugin_id);
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
if let Some(plugin) = registry.get(plugin_id) {
|
||||
plugin.wasm_plugin.call_function("shutdown", &[]).await.ok();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Plugin not found: {}", plugin_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown all plugins
|
||||
pub async fn shutdown_all(&self) -> Result<()> {
|
||||
info!("Shutting down all plugins");
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
let plugin_ids: Vec<String> = registry.list_all().iter().map(|p| p.id.clone()).collect();
|
||||
|
||||
for plugin_id in plugin_ids {
|
||||
if let Err(e) = self.shutdown_plugin(&plugin_id).await {
|
||||
error!("Failed to shutdown plugin {}: {}", plugin_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get list of all registered plugins
|
||||
pub async fn list_plugins(&self) -> Vec<PluginMetadata> {
|
||||
let registry = self.registry.read().await;
|
||||
registry
|
||||
.list_all()
|
||||
.iter()
|
||||
.map(|p| p.metadata.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get plugin metadata by ID
|
||||
pub async fn get_plugin(&self, plugin_id: &str) -> Option<PluginMetadata> {
|
||||
let registry = self.registry.read().await;
|
||||
registry.get(plugin_id).map(|p| p.metadata.clone())
|
||||
}
|
||||
|
||||
/// Check if a plugin is loaded and enabled
|
||||
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
||||
let registry = self.registry.read().await;
|
||||
registry.is_enabled(plugin_id).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reload a plugin (for hot-reload during development)
|
||||
pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> {
|
||||
if !self.config.enable_hot_reload {
|
||||
return Err(anyhow::anyhow!("Hot-reload is disabled"));
|
||||
}
|
||||
|
||||
info!("Reloading plugin: {}", plugin_id);
|
||||
|
||||
// Re-read the manifest from disk if possible, falling back to cached version
|
||||
let manifest = {
|
||||
let registry = self.registry.read().await;
|
||||
let plugin = registry
|
||||
.get(plugin_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Plugin not found"))?;
|
||||
if let Some(ref manifest_path) = plugin.manifest_path {
|
||||
pinakes_plugin_api::PluginManifest::from_file(manifest_path).unwrap_or_else(|e| {
|
||||
warn!("Failed to re-read manifest from disk, using cached: {}", e);
|
||||
plugin.manifest.clone()
|
||||
})
|
||||
} else {
|
||||
plugin.manifest.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Shutdown and unload current version
|
||||
self.shutdown_plugin(plugin_id).await?;
|
||||
{
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(plugin_id)?;
|
||||
}
|
||||
|
||||
// Reload from manifest
|
||||
self.load_plugin_from_manifest(&manifest).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_manager_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let data_dir = temp_dir.path().join("data");
|
||||
let cache_dir = temp_dir.path().join("cache");
|
||||
|
||||
let config = PluginManagerConfig::default();
|
||||
let manager = PluginManager::new(data_dir.clone(), cache_dir.clone(), config);
|
||||
|
||||
assert!(manager.is_ok());
|
||||
assert!(data_dir.exists());
|
||||
assert!(cache_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_plugins_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let data_dir = temp_dir.path().join("data");
|
||||
let cache_dir = temp_dir.path().join("cache");
|
||||
|
||||
let config = PluginManagerConfig::default();
|
||||
let manager = PluginManager::new(data_dir, cache_dir, config).unwrap();
|
||||
|
||||
let plugins = manager.list_plugins().await;
|
||||
assert_eq!(plugins.len(), 0);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue