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:
+204
-9
@@ -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, ¶ms, &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(©, &bytes).await.map_err(io_error)?;
|
||||
self.0
|
||||
.journal
|
||||
.write("core", plugin, "core", "activation copied candidate");
|
||||
let discovery = self.discover_copy(plugin, ©, &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(¤t).await.map_err(io_error)? {
|
||||
tokio::fs::rename(¤t, directory.join("plugin.prev.wasm"))
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
tokio::fs::rename(&candidate.copy, ¤t)
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user