Return compact activation summaries instead of discovery schemas
ci / check (push) Failing after 2m40s
ci / check (push) Failing after 2m40s
Card: SAU-158
This commit is contained in:
+21
-1
@@ -334,7 +334,7 @@ At start, the core starts each service in `/config/boot.yaml`, in order:
|
||||
| `core.kill` | `pid` | `{}` |
|
||||
| `core.lock` | `path`, `wait`? | `{"held": bool}` |
|
||||
| `core.unlock` | `path` | none (a notification) |
|
||||
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | the new `rpc.discover` documents |
|
||||
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | short activation summaries, keyed by plugin |
|
||||
| `core.revert` | `plugin`, `force`? | like `core.activate` |
|
||||
| `core.log` | filters (section 7) | journal entries |
|
||||
| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. |
|
||||
@@ -434,6 +434,26 @@ imported method:
|
||||
If a check fails, nothing changes, and the error -32003 lists each problem,
|
||||
with a counterexample where possible.
|
||||
|
||||
The result is an object keyed by plugin name. Each value contains:
|
||||
|
||||
- `plugin`: the plugin name.
|
||||
- `wasm`: the installed world path (`/plugins/<name>/plugin.wasm`).
|
||||
- `hash`: the SHA-256 of the installed copy.
|
||||
- `previous`: the replaced version's SHA-256, or `null` on first installation.
|
||||
For an identical-hash no-op, this is the current hash.
|
||||
- `method_count`: the number of methods in the new main interface.
|
||||
- `added`, `removed`, `changed`: sorted method-name arrays compared with the
|
||||
previous discovery document. A change to a method description or any
|
||||
transitively referenced local schema counts as changed. Unrelated schemas
|
||||
and endpoint interfaces do not affect these arrays. With no previous
|
||||
document, all methods are added.
|
||||
- `interface_check`: `"passed"` after successful checks, or `"skipped"` with
|
||||
`force: true` (not a claim of compatibility).
|
||||
|
||||
The result does not include schemas. The full document remains available
|
||||
through `<plugin>.rpc.discover` and `/proc/core/plugins/<name>/discover.json`.
|
||||
An identical-hash no-op and `core.revert` return the same summary shape.
|
||||
|
||||
- The core runs one activation at a time.
|
||||
- `plugins: [...]` activates several plugins together. The checks then use
|
||||
the new set.
|
||||
|
||||
+94
-8
@@ -6,6 +6,44 @@ use crate::{
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
/// Method descriptions plus their reachable local references, without expanding cycles.
|
||||
fn methods(document: &Value) -> BTreeMap<String, Value> {
|
||||
fn references(value: &Value, document: &Value, found: &mut BTreeMap<String, Value>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
|
||||
&& let Some(pointer) = reference.strip_prefix('#')
|
||||
&& !found.contains_key(reference)
|
||||
&& let Some(target) = document.pointer(pointer)
|
||||
{
|
||||
found.insert(reference.into(), target.clone());
|
||||
references(target, document, found);
|
||||
}
|
||||
for child in object.values() {
|
||||
references(child, document, found);
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for child in array {
|
||||
references(child, document, found);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
document["methods"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|method| {
|
||||
let name = method["name"].as_str()?;
|
||||
let mut found = BTreeMap::new();
|
||||
references(method, document, &mut found);
|
||||
Some((name.into(), json!({"method":method,"references":found})))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
plugin: String,
|
||||
copy: PathBuf,
|
||||
@@ -113,7 +151,31 @@ impl Host {
|
||||
.map(|candidate| candidate.plugin.clone())
|
||||
.collect();
|
||||
self.refresh_graph_except(&names).await?;
|
||||
let previous = self.0.graph.lock().unwrap().plugins.clone();
|
||||
let mut previous = self.0.graph.lock().unwrap().plugins.clone();
|
||||
// Startup discovery may not yet have visited a replaced plugin. Discover
|
||||
// its immutable old copy for the diff, but never prevent repairing it.
|
||||
for candidate in candidates.iter() {
|
||||
if let Some(old) = previous.get_mut(&candidate.plugin)
|
||||
&& old.document.is_null()
|
||||
&& old.error.is_none()
|
||||
{
|
||||
if old.hash == candidate.interface.hash {
|
||||
old.document = candidate.interface.document.clone();
|
||||
continue;
|
||||
}
|
||||
let directory = self.0.world.plugin_dir(&candidate.plugin);
|
||||
if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await {
|
||||
let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
|
||||
if tokio::fs::write(©, bytes).await.is_ok()
|
||||
&& let Ok(interface) =
|
||||
self.discover_copy(&candidate.plugin, ©, hash).await
|
||||
{
|
||||
*old = interface;
|
||||
}
|
||||
let _ = tokio::fs::remove_file(copy).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut proposed = previous.clone();
|
||||
for candidate in candidates.iter() {
|
||||
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
|
||||
@@ -148,7 +210,7 @@ impl Host {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.install_candidates(candidates, previous, proposed)
|
||||
self.install_candidates(candidates, previous, proposed, params["force"] != true)
|
||||
.await
|
||||
}
|
||||
/// Installs staged files and snapshots all old admissions under the shared write gate.
|
||||
@@ -157,6 +219,7 @@ impl Host {
|
||||
candidates: &[Candidate],
|
||||
previous: BTreeMap<String, Interface>,
|
||||
proposed: BTreeMap<String, Interface>,
|
||||
checked: bool,
|
||||
) -> RpcResult {
|
||||
let admission = self.0.admission.write().await;
|
||||
// Every candidate already has a temporary name before any installed file changes.
|
||||
@@ -183,12 +246,35 @@ impl Host {
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
let mut upgrades = Vec::new();
|
||||
let mut documents = serde_json::Map::new();
|
||||
let mut summaries = serde_json::Map::new();
|
||||
for candidate in candidates {
|
||||
documents.insert(
|
||||
candidate.plugin.clone(),
|
||||
candidate.interface.document.clone(),
|
||||
);
|
||||
let old = previous.get(&candidate.plugin);
|
||||
let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null));
|
||||
let after = methods(&candidate.interface.document);
|
||||
let added: Vec<_> = after
|
||||
.keys()
|
||||
.filter(|name| !before.contains_key(*name))
|
||||
.collect();
|
||||
let removed: Vec<_> = before
|
||||
.keys()
|
||||
.filter(|name| !after.contains_key(*name))
|
||||
.collect();
|
||||
let changed: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method))
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
summaries.insert(candidate.plugin.clone(), json!({
|
||||
"plugin": candidate.plugin,
|
||||
"wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin),
|
||||
"hash": candidate.interface.hash,
|
||||
"previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()),
|
||||
"method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
"interface_check": if checked { "passed" } else { "skipped" },
|
||||
}));
|
||||
if let Some(old) = previous
|
||||
.get(&candidate.plugin)
|
||||
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
|
||||
@@ -216,6 +302,6 @@ impl Host {
|
||||
for upgrade in upgrades {
|
||||
self.begin_upgrade(upgrade);
|
||||
}
|
||||
Ok(Value::Object(documents))
|
||||
Ok(Value::Object(summaries))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,86 @@ fn fixture(version: usize) -> PathBuf {
|
||||
})[version - 1]
|
||||
.clone()
|
||||
}
|
||||
fn fixture_hash(version: usize) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!(
|
||||
"{:x}",
|
||||
Sha256::digest(std::fs::read(fixture(version)).unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.3: summaries omit even large schemas and report sorted main-method diffs.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn summarizes_method_changes() {
|
||||
let mut old = document("unchanged");
|
||||
old["methods"] = json!([
|
||||
{"name":"z_removed"}, {"name":"a_removed"}, {"name":"direct", "params":[]},
|
||||
{"name":"referenced", "result":{"schema":{"$ref":"#/components/schemas/Node"}}},
|
||||
{"name":"unrelated", "result":{"schema":{"type":"string"}}}
|
||||
]);
|
||||
old["components"]["schemas"] = json!({
|
||||
"Node":{"properties":{"next":{"$ref":"#/components/schemas/Node"}, "value":{"$ref":"#/components/schemas/Value"}}},
|
||||
"Value":{"type":"string"}, "Unused":{"type":"string"}
|
||||
});
|
||||
let mut next = old.clone();
|
||||
next["methods"].as_array_mut().unwrap().drain(..2);
|
||||
next["methods"][0]["params"] = json!([{"name":"limit","schema":{"type":"integer"}}]);
|
||||
next["methods"].as_array_mut().unwrap().extend(
|
||||
json!([
|
||||
{"name":"z_added"}, {"name":"a_added"}
|
||||
])
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
next["components"]["schemas"]["Value"]["type"] = json!("integer");
|
||||
next["components"]["schemas"]["Unused"]["description"] = json!("x".repeat(220_000));
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", old, next.clone());
|
||||
let host = world.start().await;
|
||||
let result = activate(&host, "notes").await.unwrap();
|
||||
assert_eq!(result["notes"]["method_count"], 5);
|
||||
assert_eq!(result["notes"]["added"], json!(["a_added", "z_added"]));
|
||||
assert_eq!(
|
||||
result["notes"]["removed"],
|
||||
json!(["a_removed", "z_removed"])
|
||||
);
|
||||
assert_eq!(result["notes"]["changed"], json!(["direct", "referenced"]));
|
||||
assert!(result.to_string().len() < 1024);
|
||||
assert_eq!(call(&host, "notes.rpc.discover", json!({})).await, next);
|
||||
assert_eq!(
|
||||
call(
|
||||
&host,
|
||||
"notes.read",
|
||||
json!({"path":"/proc/core/plugins/notes/discover.json"})
|
||||
)
|
||||
.await,
|
||||
next
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.3: first installation has no previous build; a no-op has no method changes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn summarizes_first_install_and_noop() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::remove_file(world.0.join("plugins/notes/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
let result = activate(&host, "notes").await.unwrap();
|
||||
assert_eq!(result["notes"]["previous"], Value::Null);
|
||||
assert_eq!(result["notes"]["added"], json!(["add"]));
|
||||
assert_eq!(result["notes"]["hash"], fixture_hash(2));
|
||||
let result = activate(&host, "notes").await.unwrap();
|
||||
assert_eq!(result["notes"]["previous"], fixture_hash(2));
|
||||
for field in ["added", "removed", "changed"] {
|
||||
assert_eq!(result["notes"][field], json!([]));
|
||||
}
|
||||
assert!(!world.0.join("plugins/notes/plugin.prev.wasm").exists());
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
/// Creates an isolated world with two version documents per plugin.
|
||||
@@ -481,7 +561,19 @@ async fn activates_copy() {
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
let result = activate(&host, "notes").await.unwrap();
|
||||
assert!(result["notes"]["methods"].is_array());
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({"notes": {
|
||||
"plugin":"notes", "wasm":"/plugins/notes/plugin.wasm",
|
||||
"hash":fixture_hash(2), "previous":fixture_hash(1),
|
||||
"method_count":1, "added":[], "removed":[], "changed":[],
|
||||
"interface_check":"passed"
|
||||
}})
|
||||
);
|
||||
assert_eq!(
|
||||
call(&host, "notes.rpc.discover", json!({})).await,
|
||||
document("add")
|
||||
);
|
||||
assert_eq!(
|
||||
call(&host, "notes.version", json!({})).await["version"],
|
||||
"2"
|
||||
@@ -577,7 +669,12 @@ async fn activates_group() {
|
||||
consumer("notes", document("other")),
|
||||
);
|
||||
let host = world.start().await;
|
||||
call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
||||
let result = call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
||||
assert_eq!(result.as_object().unwrap().len(), 2);
|
||||
assert_eq!(result["notes"]["added"], json!(["other"]));
|
||||
assert_eq!(result["notes"]["removed"], json!(["add"]));
|
||||
assert_eq!(result["agent"]["method_count"], 1);
|
||||
assert_eq!(result["agent"]["interface_check"], "passed");
|
||||
assert_eq!(
|
||||
call(&host, "agent.version", json!({})).await["version"],
|
||||
"2"
|
||||
@@ -594,12 +691,13 @@ async fn force_skips_checks() {
|
||||
consumer("absent", document("add")),
|
||||
);
|
||||
let host = world.start().await;
|
||||
call(
|
||||
let result = call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result["notes"]["interface_check"], "skipped");
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: revert activates the previous copy and preserves the replaced version.
|
||||
@@ -609,7 +707,11 @@ async fn reverts_previous_version() {
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
let result = call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
assert_eq!(result["notes"]["hash"], fixture_hash(1));
|
||||
assert_eq!(result["notes"]["previous"], fixture_hash(2));
|
||||
assert_eq!(result["notes"]["interface_check"], "passed");
|
||||
assert!(result["notes"].get("methods").is_none());
|
||||
assert_eq!(
|
||||
call(&host, "notes.version", json!({})).await["version"],
|
||||
"1"
|
||||
|
||||
Reference in New Issue
Block a user