Files
Oxicloud/src/infrastructure/services/plugins/manager.rs
T
Bradley Nelson 87d68c5b6f init plugins
2026-06-16 17:57:57 -06:00

194 lines
6.9 KiB
Rust

//! Plugin discovery + dispatch. Implements [`PluginDispatchPort`] over the
//! Extism [`PluginRuntime`].
//!
//! Discovery scans a directory of plugin subdirectories (each `plugin.toml` +
//! `.wasm`) at startup; a plugin that fails validation or load is audit-logged
//! and skipped, never fatal. Dispatch builds a fresh sandbox per invocation on
//! the blocking pool, so a slow or hostile plugin never stalls async workers or
//! the upload path that triggered it.
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use serde_json::json;
use super::manifest;
use super::runtime::{InvokeOutcome, PluginRuntime};
use crate::application::ports::plugin_ports::{
EVENT_FILE_UPLOADED, FileUploadedEvent, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort,
PluginInput,
};
use crate::common::config::PluginConfig;
/// A validated, loadable plugin held in memory.
struct LoadedPlugin {
id: String,
subscribe: HashSet<String>,
runtime: Arc<PluginRuntime>,
}
/// Owns all loaded plugins and dispatches events to them.
pub struct ExtismPluginManager {
config: PluginConfig,
plugins: Vec<LoadedPlugin>,
}
impl ExtismPluginManager {
/// Scan `dir` for plugins and build a manager from those that validate and
/// load. Returns an empty manager (logging the cause) if `dir` is absent or
/// unreadable — a missing plugins directory is normal, not an error.
pub fn load_from_dir(config: PluginConfig, dir: &Path) -> Self {
let mut plugins = Vec::new();
let mut rejected = 0usize;
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
tracing::info!(
target: "oxicloud::plugins",
dir = %dir.display(),
error = %e,
"plugins directory not readable; no plugins loaded"
);
return Self { config, plugins };
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
match Self::load_one(&config, &path) {
Ok(loaded) => {
tracing::info!(
target: "oxicloud::plugins",
plugin_id = %loaded.id,
dir = %path.display(),
"plugin loaded"
);
plugins.push(loaded);
}
Err(reason) => {
rejected += 1;
tracing::warn!(
target: "audit",
event = "plugin.load_rejected",
reason = reason,
plugin_dir = %path.display(),
"👮🏻‍♂️ plugin rejected at load"
);
}
}
}
tracing::info!(
target: "oxicloud::plugins",
loaded = plugins.len(),
rejected,
dir = %dir.display(),
"plugin discovery complete"
);
Self { config, plugins }
}
/// Validate and load a single plugin directory. Returns a stable audit
/// `reason` key on rejection.
fn load_one(config: &PluginConfig, dir: &Path) -> Result<LoadedPlugin, &'static str> {
let manifest_path = dir.join("plugin.toml");
if !manifest_path.exists() {
return Err("no_manifest");
}
let toml_str =
std::fs::read_to_string(&manifest_path).map_err(|_| "manifest_unreadable")?;
let manifest = manifest::parse_and_validate(&toml_str).map_err(|e| e.reason())?;
let wasm_path = dir.join(&manifest.plugin.entrypoint);
let wasm_bytes = std::fs::read(&wasm_path).map_err(|_| "wasm_unreadable")?;
let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm_bytes);
// Probe abi_version on a throwaway instance; rejects lying/unloadable wasm.
match runtime.check_loadable(config) {
InvokeOutcome::Ok => {}
InvokeOutcome::AbiMismatch { .. } => return Err("abi_mismatch"),
_ => return Err("not_loadable"),
}
Ok(LoadedPlugin {
id: manifest.plugin.id,
subscribe: manifest.events.subscribe.into_iter().collect(),
runtime: Arc::new(runtime),
})
}
/// Number of successfully loaded plugins (used by DI for the startup summary
/// and by tests).
pub fn loaded_count(&self) -> usize {
self.plugins.len()
}
}
impl PluginDispatchPort for ExtismPluginManager {
fn dispatch_file_uploaded(&self, event: FileUploadedEvent) {
for plugin in &self.plugins {
if !plugin.subscribe.contains(EVENT_FILE_UPLOADED) {
continue;
}
let input = PluginInput {
abi: OXICLOUD_PLUGIN_ABI,
event: EVENT_FILE_UPLOADED.to_string(),
context: PluginContext {
plugin_id: plugin.id.clone(),
user_id: event.user_id.clone(),
invocation_id: event.invocation_id.clone(),
},
payload: json!({
"path": event.path,
"size": event.size,
"mime": event.mime,
}),
};
let input_json = match serde_json::to_string(&input) {
Ok(j) => j,
Err(e) => {
tracing::warn!(
target: "oxicloud::plugins",
plugin_id = %plugin.id,
error = %e,
"failed to serialize plugin input; skipping"
);
continue;
}
};
let runtime = plugin.runtime.clone();
let config = self.config.clone();
let plugin_id = plugin.id.clone();
let invocation_id = event.invocation_id.clone();
// Run the synchronous wasm call off the async workers. Fire-and-forget:
// the upload already succeeded; plugins are post-hoc observers.
tokio::task::spawn_blocking(move || {
let result = runtime.invoke(&config, &invocation_id, &input_json);
if !result.outcome.is_ok() {
tracing::warn!(
target: "audit",
event = "plugin.invocation_failed",
reason = result.outcome.reason(),
plugin_id = %plugin_id,
invocation_id = %invocation_id,
detail = ?result.outcome,
"👮🏻‍♂️ plugin invocation failed"
);
}
});
}
}
fn has_subscribers(&self, event: &str) -> bool {
self.plugins.iter().any(|p| p.subscribe.contains(event))
}
}