Files
sausage/cli/src/graph.rs
T

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(&copy, &bytes).await {
Ok(()) => self.discover_copy(&name, &copy, hash.clone()).await,
Err(error) => Err(io_error(error)),
};
let _ = tokio::fs::remove_file(&copy).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(), &current.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())
}