core: sausage-vfs per process (/, /proc, /self, /run), activation, graph, watcher, upgrades
- /proc is a core layer: core/ (PROTOCOL.md, graph.json, graph.dot, locks.json, plugins/<name>/discover.json and client.rs) and one folder per process (info.json, log, and its /self files). - Locks are released only after the task ended, the store dropped, and in-flight file operations returned. - core.activate and core.revert with copies, discovery, compatibility checks of imports and consumers, groups, force. An admission gate makes the snapshot of old processes complete. - Graph of discover documents by hash, refreshed by a file watcher. - Upgrades: grace periods (x-sausage-grace), stop, service restarts, rpc.retired. - SDK plugins in the real core (cli/tests/sdk.rs).
This commit is contained in:
+253
-27
@@ -1,31 +1,257 @@
|
||||
//! The interface graph (PROTOCOL.md section 10.4): the `rpc.discover`
|
||||
//! documents of all plugins, their imports, and the result of each check.
|
||||
//!
|
||||
//! The `/proc` layer reads the files of `/proc/core/` through these
|
||||
//! functions. They must be cheap and must not block: they return cached data.
|
||||
use crate::Host;
|
||||
use serde_json::Value;
|
||||
//! 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, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
// The /proc layer (T6) uses these functions. Remove this when it does.
|
||||
#[allow(dead_code)]
|
||||
impl Host {
|
||||
/// The content of `/proc/core/graph.json`.
|
||||
pub(crate) fn graph_json(&self) -> Vec<u8> {
|
||||
b"{}\n".to_vec()
|
||||
}
|
||||
#[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>,
|
||||
pub(crate) versions: HashMap<String, Value>,
|
||||
json: Vec<u8>,
|
||||
dot: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The content of `/proc/core/graph.dot`.
|
||||
pub(crate) fn graph_dot(&self) -> Vec<u8> {
|
||||
b"digraph plugins {}\n".to_vec()
|
||||
}
|
||||
|
||||
/// The plugins that have an `rpc.discover` document, for `/proc/core/plugins/`.
|
||||
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The `rpc.discover` document of the current `plugin.wasm` of `plugin`.
|
||||
pub(crate) fn discover_document(&self, _plugin: &str) -> Option<Value> {
|
||||
None
|
||||
impl Default for Graph {
|
||||
/// Exposes a valid empty graph before background discovery completes.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugins: BTreeMap::new(),
|
||||
versions: HashMap::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))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
bytes: &[u8],
|
||||
) -> Result<Interface, Value> {
|
||||
let hash = hash(bytes);
|
||||
let cache_key = format!("{plugin}/{hash}");
|
||||
let document = self
|
||||
.route_from(
|
||||
"core".into(),
|
||||
Some(json!(uuid::Uuid::now_v7().to_string())),
|
||||
format!("{plugin}.rpc.discover"),
|
||||
json!({}),
|
||||
true,
|
||||
Some(path.to_path_buf()),
|
||||
)
|
||||
.await?;
|
||||
if !document["methods"].is_array() {
|
||||
return Err(crate::error(
|
||||
-32003,
|
||||
"rpc.discover did not return an OpenRPC document",
|
||||
));
|
||||
}
|
||||
self.0
|
||||
.graph
|
||||
.lock()
|
||||
.unwrap()
|
||||
.versions
|
||||
.insert(cache_key, document.clone());
|
||||
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 {
|
||||
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)? {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let source = entry.path().join("plugin.wasm");
|
||||
let bytes = match tokio::fs::read(&source).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(io_error(error)),
|
||||
};
|
||||
let hash = hash(&bytes);
|
||||
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
|
||||
let interface = 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()));
|
||||
tokio::fs::write(©, &bytes).await.map_err(io_error)?;
|
||||
let result = self.discover_copy(&name, ©, &bytes).await;
|
||||
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 != current.hash && current.error.is_none())
|
||||
{
|
||||
let grace = old.document["x-sausage-grace"]
|
||||
.as_f64()
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(60.0);
|
||||
upgrades.push(self.snapshot_upgrade(
|
||||
plugin,
|
||||
old.hash.clone(),
|
||||
¤t.hash,
|
||||
grace,
|
||||
));
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user