289 lines
11 KiB
Rust
289 lines
11 KiB
Rust
//! Cached discovery documents and import checks under PROTOCOL.md section 10.4.
|
|
//! Refreshes use immutable copies. The proc layer only reads serialized cache entries.
|
|
use crate::{Host, RpcResult};
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct Interface {
|
|
pub(crate) hash: String,
|
|
pub(crate) document: Value,
|
|
pub(crate) error: Option<Value>,
|
|
}
|
|
pub(crate) struct Graph {
|
|
pub(crate) plugins: BTreeMap<String, Interface>,
|
|
json: Vec<u8>,
|
|
dot: Vec<u8>,
|
|
}
|
|
|
|
impl Default for Graph {
|
|
/// Exposes a valid empty graph before background discovery completes.
|
|
fn default() -> Self {
|
|
Self {
|
|
plugins: BTreeMap::new(),
|
|
json: b"{\"plugins\":{}}".to_vec(),
|
|
dot: b"digraph plugins {}\n".to_vec(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hashes exactly the bytes copied for discovery or installation.
|
|
pub(crate) fn hash(bytes: &[u8]) -> String {
|
|
format!("{:x}", Sha256::digest(bytes))
|
|
}
|
|
|
|
/// Reads and hashes one immutable snapshot without blocking an async worker.
|
|
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
|
|
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
|
|
let bytes = std::fs::read(path)?;
|
|
let hash = hash(&bytes);
|
|
Ok((bytes, hash))
|
|
})
|
|
.await
|
|
.map_err(std::io::Error::other)?
|
|
}
|
|
|
|
/// Lists broken imports, optionally restricted to changed consumers or providers.
|
|
pub(crate) fn problems(
|
|
plugins: &BTreeMap<String, Interface>,
|
|
changed: Option<&[String]>,
|
|
) -> Vec<Value> {
|
|
let mut failures = Vec::new();
|
|
for (consumer, interface) in plugins {
|
|
if let Some(imports) = interface.document["x-sausage-imports"].as_object() {
|
|
for (provider, expected) in imports {
|
|
if changed
|
|
.is_some_and(|names| !names.contains(consumer) && !names.contains(provider))
|
|
{
|
|
continue;
|
|
}
|
|
let Some(actual) = plugins.get(provider) else {
|
|
failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"}));
|
|
continue;
|
|
};
|
|
if let Some(error) = &actual.error {
|
|
failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error}));
|
|
continue;
|
|
}
|
|
for problem in sausage_interface::compat::check(expected, &actual.document) {
|
|
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
failures
|
|
}
|
|
|
|
impl Host {
|
|
/// The cached JSON graph. This does not perform filesystem or RPC operations.
|
|
pub(crate) fn graph_json(&self) -> Vec<u8> {
|
|
self.0.graph.lock().unwrap().json.clone()
|
|
}
|
|
/// The cached DOT graph, with an edge for every import.
|
|
pub(crate) fn graph_dot(&self) -> Vec<u8> {
|
|
self.0.graph.lock().unwrap().dot.clone()
|
|
}
|
|
/// Plugin names currently present in the discovery cache.
|
|
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
|
|
self.0
|
|
.graph
|
|
.lock()
|
|
.unwrap()
|
|
.plugins
|
|
.keys()
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
/// The current document, if discovery succeeded.
|
|
pub(crate) fn discover_document(&self, plugin: &str) -> Option<Value> {
|
|
self.0
|
|
.graph
|
|
.lock()
|
|
.unwrap()
|
|
.plugins
|
|
.get(plugin)
|
|
.filter(|entry| entry.error.is_none())
|
|
.map(|entry| entry.document.clone())
|
|
}
|
|
/// Discovers a private copy and caches successful documents by file hash.
|
|
pub(crate) async fn discover_copy(
|
|
&self,
|
|
plugin: &str,
|
|
path: &Path,
|
|
hash: String,
|
|
) -> Result<Interface, Value> {
|
|
let request = json!(uuid::Uuid::now_v7().to_string());
|
|
let discovery = self.route_from(
|
|
"core".into(),
|
|
Some(request.clone()),
|
|
format!("{plugin}.rpc.discover"),
|
|
json!({}),
|
|
true,
|
|
Some(path.to_path_buf()),
|
|
);
|
|
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
|
|
let processes: Vec<_> = self
|
|
.0
|
|
.state
|
|
.lock()
|
|
.unwrap()
|
|
.processes
|
|
.iter()
|
|
.filter(|(_, process)| process.id.as_ref() == Some(&request))
|
|
.map(|(pid, _)| pid.clone())
|
|
.collect();
|
|
for pid in processes {
|
|
self.begin_stop(&pid);
|
|
}
|
|
let document = result
|
|
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
|
|
if !document["methods"].is_array() {
|
|
return Err(crate::error(
|
|
-32003,
|
|
"rpc.discover did not return an OpenRPC document",
|
|
));
|
|
}
|
|
Ok(Interface {
|
|
hash,
|
|
document,
|
|
error: None,
|
|
})
|
|
}
|
|
/// Refreshes every installed plugin while the caller holds the activation mutex.
|
|
pub(crate) async fn refresh_graph(&self) -> RpcResult {
|
|
self.refresh_graph_except(&[]).await
|
|
}
|
|
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
|
|
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
|
|
.await
|
|
.map_err(io_error)?;
|
|
let mut plugins = BTreeMap::new();
|
|
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
|
|
if !entry.file_type().await.map_err(io_error)?.is_dir() {
|
|
continue;
|
|
}
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
let source = entry.path().join("plugin.wasm");
|
|
let (bytes, hash) = match read_hashed(source).await {
|
|
Ok(snapshot) => snapshot,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
|
Err(error) => {
|
|
plugins.insert(
|
|
name,
|
|
Interface {
|
|
hash: String::new(),
|
|
document: Value::Null,
|
|
error: Some(io_error(error)),
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
|
|
let interface = if candidates.contains(&name) {
|
|
previous
|
|
.filter(|entry| entry.hash == hash)
|
|
.unwrap_or(Interface {
|
|
hash,
|
|
document: Value::Null,
|
|
error: None,
|
|
})
|
|
} else if let Some(previous) =
|
|
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
|
|
{
|
|
previous
|
|
} else {
|
|
let copy = entry
|
|
.path()
|
|
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
|
|
let result = match tokio::fs::write(©, &bytes).await {
|
|
Ok(()) => self.discover_copy(&name, ©, hash.clone()).await,
|
|
Err(error) => Err(io_error(error)),
|
|
};
|
|
let _ = tokio::fs::remove_file(©).await;
|
|
match result {
|
|
Ok(interface) => interface,
|
|
Err(error) => Interface {
|
|
hash,
|
|
document: Value::Null,
|
|
error: Some(error),
|
|
},
|
|
}
|
|
};
|
|
plugins.insert(name, interface);
|
|
}
|
|
let admission = self.0.admission.write().await;
|
|
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
|
|
let mut upgrades = Vec::new();
|
|
for (plugin, current) in &plugins {
|
|
if let Some(old) = old_plugins.get(plugin).filter(|old| {
|
|
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
|
|
}) {
|
|
upgrades.push(self.snapshot_upgrade(plugin, old.hash.clone(), ¤t.hash));
|
|
}
|
|
}
|
|
self.publish_graph(plugins);
|
|
drop(admission);
|
|
for upgrade in upgrades {
|
|
self.begin_upgrade(upgrade);
|
|
}
|
|
Ok(json!({}))
|
|
}
|
|
/// Rebuilds serialized snapshots after a watcher refresh or an activation.
|
|
pub(crate) fn publish_graph(&self, plugins: BTreeMap<String, Interface>) {
|
|
let failures = problems(&plugins, None);
|
|
for failure in &failures {
|
|
self.0
|
|
.journal
|
|
.write("core", "core", "core", &format!("broken import {failure}"));
|
|
}
|
|
let mut entries = serde_json::Map::new();
|
|
let mut dot = String::from("digraph plugins {\n");
|
|
for (name, interface) in &plugins {
|
|
dot.push_str(&format!(" {};\n", json!(name)));
|
|
let mut imports = serde_json::Map::new();
|
|
for provider in interface.document["x-sausage-imports"]
|
|
.as_object()
|
|
.into_iter()
|
|
.flat_map(|imports| imports.keys())
|
|
{
|
|
let issues: Vec<_> = failures
|
|
.iter()
|
|
.filter(|problem| {
|
|
problem["consumer"] == *name && problem["provider"] == *provider
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
let status = issues
|
|
.first()
|
|
.and_then(|problem| problem["status"].as_str())
|
|
.unwrap_or("compatible");
|
|
imports.insert(provider.clone(), json!({"status":status,"problems":issues}));
|
|
dot.push_str(&format!(
|
|
" {} -> {} [label={}];\n",
|
|
json!(name),
|
|
json!(provider),
|
|
json!(status)
|
|
));
|
|
}
|
|
entries.insert(
|
|
name.clone(),
|
|
json!({"hash":interface.hash,"imports":imports,"error":interface.error}),
|
|
);
|
|
}
|
|
dot.push_str("}\n");
|
|
let mut graph = self.0.graph.lock().unwrap();
|
|
graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap();
|
|
graph.dot = dot.into_bytes();
|
|
graph.plugins = plugins;
|
|
}
|
|
}
|
|
/// Converts a filesystem failure into a JSON-RPC internal error.
|
|
pub(crate) fn io_error(error: std::io::Error) -> Value {
|
|
crate::error(-32603, &error.to_string())
|
|
}
|