Sausage: host, broker, commit-on-close filesystem, cargo actor, console, agent

Imported from the spike branch, with:
- a library crate (Host API) for the CLI and the tests
- JSON test scenarios for candidates
- commit on close only for new or truncated files
- a Nix flake: nix run -- init, nix run -- run
This commit is contained in:
2026-10-15 10:45:23 +01:00
commit 1f49d0f7bb
42 changed files with 7126 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
//! Tests for a candidate version, before the broker installs it.
//!
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
//!
//! ```json
//! {
//! "description": "add, then list",
//! "steps": [
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
//! ]
//! }
//! ```
//!
//! Each step sends `send` (optional, from `from` or "test"), then reads one
//! message for each entry of `expect` (an object or a list). An expected body
//! matches if it is a subset of the actual body. With `to`, each message must
//! also go to that recipient.
//!
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
//! `/public`. The candidate has no HTTP access. Without test files, the
//! candidate must start and stay up for `SMOKE_TIME`.
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use serde_json::{Value, json};
use wasmtime::component::Component;
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
use crate::fs::Mount;
use crate::runtime::{Instance, InstanceConfig};
use crate::world;
const SMOKE_TIME: Duration = Duration::from_secs(2);
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
/// `expected` matches `actual` if every key of an expected object exists in
/// the actual object with a matching value. Other values must be equal.
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
_ => expected == actual,
}
}
struct Scratch {
dir: std::path::PathBuf,
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn stderr_tail(path: &Path) -> String {
let text = std::fs::read_to_string(path).unwrap_or_default();
let start = text.len().saturating_sub(2000);
let mut s = start;
while !text.is_char_boundary(s) {
s += 1;
}
text[s..].to_string()
}
pub async fn test(
broker: &Broker,
id: &str,
component: &Component,
source: &Path,
data: Option<&Path>,
public: Option<&Path>,
) -> Result<Value> {
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
.map(|rd| {
rd.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect()
})
.unwrap_or_default();
files.sort();
if files.is_empty() {
let (mut inst, scratch) = start(broker, id, component, data, public)?;
tokio::time::sleep(SMOKE_TIME).await;
if inst.is_finished() {
let exit = inst.wait().await;
bail!(
"candidate ended during the smoke test ({exit}). stderr:\n{}",
stderr_tail(&scratch.dir.join("stderr.log"))
);
}
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
return Ok(json!({"smoke_test": "passed", "files": 0}));
}
let mut passed = Vec::new();
for file in &files {
let name = file.file_name().unwrap().to_string_lossy().to_string();
run_file(broker, id, component, data, public, file)
.await
.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
passed.push(name);
}
Ok(json!({"passed": passed}))
}
fn start(broker: &Broker, id: &str, component: &Component, data: Option<&Path>, public: Option<&Path>) -> Result<(Instance, Scratch)> {
let dir = broker.world.scratch_dir().join(format!("{id}-{}-{}", world::now(), rand_suffix()));
let scratch = Scratch { dir: dir.clone() };
std::fs::create_dir_all(dir.join("data"))?;
std::fs::create_dir_all(dir.join("public"))?;
if let Some(d) = data.filter(|d| d.is_dir()) {
world::copy_tree(d, &dir.join("data"))?;
}
if let Some(p) = public.filter(|p| p.is_dir()) {
world::copy_tree(p, &dir.join("public"))?;
}
let cfg = InstanceConfig {
mounts: vec![
Mount { guest: "/actor".into(), host: broker.world.actors_root(), writable: false },
Mount { guest: "/public".into(), host: dir.join("public"), writable: true },
Mount { guest: "/data".into(), host: dir.join("data"), writable: true },
],
allow_http: false,
stderr_log: dir.join("stderr.log"),
env: vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_CANDIDATE".into(), "1".into())],
};
Ok((broker.runtime.start(component, cfg)?, scratch))
}
fn rand_suffix() -> u32 {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u64(std::process::id() as u64);
h.finish() as u32
}
async fn run_file(
broker: &Broker,
id: &str,
component: &Component,
data: Option<&Path>,
public: Option<&Path>,
file: &Path,
) -> Result<()> {
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
let (mut inst, scratch) = start(broker, id, component, data, public)?;
let stderr = scratch.dir.join("stderr.log");
for (n, step) in steps.iter().enumerate() {
let n = n + 1;
if let Some(body) = step.get("send") {
let from = step["from"].as_str().unwrap_or("test");
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
inst.send_line(&env.to_string())
.await
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
}
let expected = match step.get("expect") {
None => vec![],
Some(Value::Array(list)) => list.clone(),
Some(one) => vec![one.clone()],
};
for expected in expected {
let line = match tokio::time::timeout(EXPECT_TIMEOUT, inst.stdout.recv()).await {
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
Ok(None) => {
let exit = inst.wait().await;
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
}
Ok(Some(l)) => l,
};
let msg: Value = serde_json::from_str(&line)
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
if !subset_match(&expected, &msg["body"]) {
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
}
if let Some(to) = step.get("to")
&& msg.get("to") != Some(to)
{
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
}
}
}
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
Ok(())
}