Host: fixes from the review, with regression tests
- one live instance per actor: check after the lock; dropping an instance kills it - state snapshots keep target/ and .git/; only source copies skip them - one shutdown deadline; output during shutdown is still routed - deliveries route stdout meanwhile; a stuck stdin kills the instance (no partial lines) and the sender gets undeliverable; control requests are not blocked by a stuck delivery - Host::stop aborts running broker and cargo requests first - compile a version before installing it; roll back if it does not start - unique archive names; a failed archive keeps the actor manageable - commit on close: truncate without create; directory renames - candidates see SAUSAGE_VERSION and the existing state of a new actor - the host publishes PROTOCOL.md at /actor/broker/public/
This commit is contained in:
+88
-77
@@ -18,7 +18,7 @@
|
||||
//! 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
|
||||
//! `/public` (empty folders for a new actor). The candidate has no HTTP access. Without test files, the
|
||||
//! candidate must start and stay up for `SMOKE_TIME`.
|
||||
|
||||
use std::path::Path;
|
||||
@@ -66,14 +66,26 @@ fn stderr_tail(path: &Path) -> String {
|
||||
text[s..].to_string()
|
||||
}
|
||||
|
||||
/// The version under test and the state that its tests start from.
|
||||
struct Candidate<'a> {
|
||||
broker: &'a Broker,
|
||||
id: &'a str,
|
||||
version: u64,
|
||||
component: &'a Component,
|
||||
data: &'a Path,
|
||||
public: &'a Path,
|
||||
}
|
||||
|
||||
pub async fn test(
|
||||
broker: &Broker,
|
||||
id: &str,
|
||||
version: u64,
|
||||
component: &Component,
|
||||
source: &Path,
|
||||
data: Option<&Path>,
|
||||
public: Option<&Path>,
|
||||
data: &Path,
|
||||
public: &Path,
|
||||
) -> Result<Value> {
|
||||
let candidate = Candidate { broker, id, version, component, data, public };
|
||||
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
@@ -85,7 +97,7 @@ pub async fn test(
|
||||
files.sort();
|
||||
|
||||
if files.is_empty() {
|
||||
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||
let (mut inst, scratch) = candidate.start()?;
|
||||
tokio::time::sleep(SMOKE_TIME).await;
|
||||
if inst.is_finished() {
|
||||
let exit = inst.wait().await;
|
||||
@@ -94,94 +106,93 @@ pub async fn test(
|
||||
stderr_tail(&scratch.dir.join("stderr.log"))
|
||||
);
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
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:#}"))?;
|
||||
candidate.run_file(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)> {
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = broker.world.scratch_dir().join(format!("{id}-{}-{n}", std::process::id()));
|
||||
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))
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
impl Candidate<'_> {
|
||||
/// Start an instance on a fresh copy of the state, without HTTP.
|
||||
fn start(&self) -> Result<(Instance, Scratch)> {
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
|
||||
let scratch = Scratch { dir: dir.clone() };
|
||||
for (from, name) in [(self.data, "data"), (self.public, "public")] {
|
||||
std::fs::create_dir_all(dir.join(name))?;
|
||||
if from.is_dir() {
|
||||
world::copy_tree(from, &dir.join(name))?;
|
||||
}
|
||||
}
|
||||
let expected = match step.get("expect") {
|
||||
None => vec![],
|
||||
Some(Value::Array(list)) => list.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
let cfg = InstanceConfig {
|
||||
mounts: vec![
|
||||
Mount { guest: "/actor".into(), host: self.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(), self.id.to_string()),
|
||||
("SAUSAGE_VERSION".into(), self.version.to_string()),
|
||||
("SAUSAGE_CANDIDATE".into(), "1".into()),
|
||||
],
|
||||
};
|
||||
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));
|
||||
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
|
||||
}
|
||||
|
||||
async fn run_file(&self, 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) = self.start()?;
|
||||
let stderr = scratch.dir.join("stderr.log");
|
||||
// Lines that the candidate wrote while a step was sent.
|
||||
let mut early = std::collections::VecDeque::new();
|
||||
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(), |line| early.push_back(line))
|
||||
.await
|
||||
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
|
||||
}
|
||||
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));
|
||||
let expected = match step.get("expect") {
|
||||
None => vec![],
|
||||
Some(Value::Array(list)) => list.clone(),
|
||||
Some(one) => vec![one.clone()],
|
||||
};
|
||||
for expected in expected {
|
||||
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
|
||||
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).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(())
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user