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:
2026-10-23 01:05:19 +01:00
parent 151550242b
commit edc2d1a63b
21 changed files with 2306 additions and 101 deletions
+204 -9
View File
@@ -1,15 +1,210 @@
//! Activation and revert of plugin versions (PROTOCOL.md section 10.3).
use crate::{Host, RpcResult, error};
use serde_json::Value;
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
use crate::{
Host, RpcResult, error,
graph::{Interface, io_error, problems},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};
struct Candidate {
plugin: String,
copy: PathBuf,
interface: Interface,
}
impl Host {
/// `core.activate` and `core.revert`.
pub(crate) async fn activation(
/// Copies first, discovers that copy, checks the combined graph, then installs it.
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
let _activation = self.0.activations.lock().await;
let mut candidates = Vec::new();
let result = self
.activate_candidates(method, &params, &mut candidates)
.await;
for candidate in candidates {
let _ = tokio::fs::remove_file(candidate.copy).await;
}
result
}
/// Prepares every member before graph checks or changes to installed files.
async fn activate_candidates(
&self,
_caller: &str,
_method: &str,
_params: Value,
method: &str,
params: &Value,
candidates: &mut Vec<Candidate>,
) -> RpcResult {
Err(error(-32601, "method not found"))
let requests = params
.get("plugins")
.and_then(Value::as_array)
.cloned()
.unwrap_or_else(|| vec![params.clone()]);
if requests.is_empty() {
return Err(error(-32602, "plugins must not be empty"));
}
for request in requests {
let plugin = request["plugin"]
.as_str()
.ok_or_else(|| error(-32602, "plugin must be a string"))?;
if plugin.is_empty()
|| !plugin.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| byte == b'_'
|| byte == b'-'
})
|| candidates
.iter()
.any(|candidate| candidate.plugin == plugin)
{
return Err(error(-32602, "invalid or duplicate plugin"));
}
let directory = self.0.world.plugin_dir(plugin);
tokio::fs::create_dir_all(&directory)
.await
.map_err(io_error)?;
let source = if method == "revert" {
directory.join("plugin.prev.wasm")
} else {
let source = PathBuf::from(
request["wasm"]
.as_str()
.ok_or_else(|| error(-32602, "wasm must be a string"))?,
);
if source.starts_with(&self.0.world.root) {
source
} else {
self.0
.world
.root
.join(source.strip_prefix("/").unwrap_or(&source))
}
};
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
// Read once. Discovery and installation both use the same private bytes.
let bytes = tokio::fs::read(source).await.map_err(io_error)?;
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, &bytes).await;
let interface = match discovery {
Ok(interface) => interface,
Err(error) => {
let _ = tokio::fs::remove_file(copy).await;
return Err(error);
}
};
self.0
.journal
.write("core", plugin, "core", "activation discovered candidate");
candidates.push(Candidate {
plugin: plugin.into(),
copy,
interface,
});
}
self.refresh_graph().await?;
let previous = self.0.graph.lock().unwrap().plugins.clone();
if previous.values().any(|entry| entry.error.is_some()) {
return Err(error(
-32003,
"activation waits for successful discovery of every plugin",
));
}
let mut proposed = previous.clone();
let names: Vec<_> = candidates
.iter()
.map(|candidate| candidate.plugin.clone())
.collect();
for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
}
if params["force"] != true {
let failures = problems(&proposed, Some(&names));
if !failures.is_empty() {
self.0.journal.write(
"core",
"core",
"core",
&format!("activation rejected {}", json!(failures)),
);
let details = failures
.iter()
.map(|problem| {
format!(
"- {} imports {}: {}",
problem["consumer"],
problem["provider"],
problem["display"]
.as_str()
.unwrap_or_else(|| problem["message"]
.as_str()
.unwrap_or("incompatible import"))
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(
json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}),
);
}
}
self.install_candidates(candidates, previous, proposed)
.await
}
/// Installs staged files and snapshots all old admissions under the shared write gate.
async fn install_candidates(
&self,
candidates: &[Candidate],
previous: BTreeMap<String, Interface>,
proposed: BTreeMap<String, Interface>,
) -> RpcResult {
let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes.
for candidate in candidates {
let directory = self.0.world.plugin_dir(&candidate.plugin);
let current = directory.join("plugin.wasm");
if tokio::fs::try_exists(&current).await.map_err(io_error)? {
tokio::fs::rename(&current, directory.join("plugin.prev.wasm"))
.await
.map_err(io_error)?;
}
tokio::fs::rename(&candidate.copy, &current)
.await
.map_err(io_error)?;
}
let mut upgrades = Vec::new();
let mut documents = serde_json::Map::new();
for candidate in candidates {
documents.insert(
candidate.plugin.clone(),
candidate.interface.document.clone(),
);
if let Some(old) = previous
.get(&candidate.plugin)
.filter(|old| old.hash != candidate.interface.hash)
{
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(
&candidate.plugin,
old.hash.clone(),
&candidate.interface.hash,
grace,
));
}
self.0.journal.write(
"core",
&candidate.plugin,
"core",
&format!("activation installed {}", candidate.interface.hash),
);
}
self.publish_graph(proposed);
drop(admission);
for upgrade in upgrades {
self.begin_upgrade(upgrade);
}
Ok(Value::Object(documents))
}
}