//! The fleet's operations through the `wai` binary (extensions/fleet.md ยง9), as an agent runs //! them, on the toolchain's agent rules: the JSON envelope with `--json`, the exit statuses, //! `--dry-run` that writes and sends nothing and names what it would, and `--yes` before a fleet //! service is changed or a file replaced (`--confirm`, its deprecated spelling, does the same); //! a refusal of the service named by the fleet's reason; a report's bundle verified here before //! it is written; and `wai verify` checking a bundle offline and refusing it altered. The fleet //! service is played by a server in the test that answers as `wai-fleet` does. #![cfg(feature = "quickstart")] use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::mpsc; use serde_json::{json, Value}; fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("wai-cli-fleet-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } /// `wai` run in `dir` as an agent runs it: no terminal, and `--json`. fn wai(dir: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).arg("--json").stdin(std::process::Stdio::null()).output().expect("wai runs") } /// `wai` run in `dir` as a person scripts it: no terminal, text. fn wai_text(dir: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).stdin(std::process::Stdio::null()).output().expect("wai runs") } /// The envelope a `--json` run printed. fn envelope(o: &Output) -> Value { serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{e}: {}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))) } fn corpus() -> Value { let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/fleet/vectors.json"); serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap() } fn vector(name: &str) -> Value { corpus()["vectors"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap().clone() } /// A server answering each request with the next of `answers` (status and JSON body), and /// passing on each request's first line and body. fn service(answers: Vec<(u16, Value)>) -> (String, mpsc::Receiver<(String, String)>) { let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = format!("http://{}", l.local_addr().unwrap()); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for (status, body) in answers { let (mut s, _) = l.accept().unwrap(); let mut buf = Vec::new(); let mut chunk = [0u8; 65536]; // The head, then as much body as it states. loop { let n = s.read(&mut chunk).unwrap(); buf.extend_from_slice(&chunk[..n]); if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") { let head = String::from_utf8_lossy(&buf[..at]).to_string(); let len = head.lines().find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length: ").and_then(|v| v.parse::().ok())).unwrap_or(0); while buf.len() < at + 4 + len { let n = s.read(&mut chunk).unwrap(); buf.extend_from_slice(&chunk[..n]); } let first = head.lines().next().unwrap_or("").to_string(); tx.send((first, String::from_utf8_lossy(&buf[at + 4..]).to_string())).unwrap(); break; } if n == 0 { break; } } let b = body.to_string(); let _ = s.write_all(format!("HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{b}", b.len()).as_bytes()); } }); (addr, rx) } const NODE: &str = "1111111111111111111111111111111111111111111111111111111111111111"; #[test] fn enrolment_create_signs_writes_and_waits_for_confirmation() { let d = tmp("enrol"); std::fs::write(d.join("org.key"), "07".repeat(32)).unwrap(); // A dry run writes nothing, and its plan names the file. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe", "--dry-run"]); assert_eq!(o.status.code(), Some(0)); let e = envelope(&o); assert_eq!((e["op"].as_str(), e["ok"].as_bool(), e["dry_run"].as_bool()), (Some("enrolment.create"), Some(true), Some(true))); assert_eq!((e["plan"][0]["write"].as_str(), e["plan"][0]["replaces"].as_bool()), (Some("n.wfe"), Some(false)), "{e:#}"); assert!(!d.join("n.wfe").exists(), "a dry run writes nothing"); // The enrolment, written: it reads and its signature verifies. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe"]); assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stderr)); let e = envelope(&o); let b = std::fs::read(d.join("n.wfe")).unwrap(); let parsed = wai::fleet::Enrolment::parse(&b).unwrap(); assert_eq!((wai::fleet::hex(&parsed.node), parsed.role, parsed.label_text()), (NODE.to_string(), 1, "home".to_string())); assert_eq!(e["data"]["digest"], json!(wai::fleet::hex(&parsed.digest()))); // Replacing it waits for --yes: exit 4, with the proposal and the command that confirms. let o = wai(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe"]); assert_eq!(o.status.code(), Some(4)); let e = envelope(&o); assert_eq!(e["error"]["code"], "confirmation-required"); assert_eq!((e["proposed"][0]["write"].as_str(), e["proposed"][0]["replaces"].as_bool()), (Some("n.wfe"), Some(true)), "{e:#}"); assert_eq!(e["confirm"], format!("wai enrolment create {NODE} --role ingest --key org.key --out n.wfe --yes"), "{e:#}"); assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "nothing changed"); // In text, the same: exit 4, nothing changed, and the confirming command on standard error. let o = wai_text(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe"]); assert_eq!(o.status.code(), Some(4)); assert!(String::from_utf8_lossy(&o.stderr).contains("to go ahead: wai enrolment create"), "{}", String::from_utf8_lossy(&o.stderr)); assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "nothing changed"); // --yes replaces it; --confirm, the deprecated spelling, does the same. let o = wai(&d, &["enrolment", "create", NODE, "--role", "ingest", "--key", "org.key", "--out", "n.wfe", "--yes"]); assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stderr)); assert_eq!(wai::fleet::Enrolment::parse(&std::fs::read(d.join("n.wfe")).unwrap()).unwrap().role, 3); let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--label", "home", "--out", "n.wfe", "--confirm"]); assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stderr)); assert_eq!(std::fs::read(d.join("n.wfe")).unwrap(), b, "--confirm went ahead as --yes does"); // Its own rules refuse it before anything is written: a node that is the organisation. let org = wai::fleet::hex(&ed25519_dalek::SigningKey::from_bytes(&[7; 32]).verifying_key().to_bytes()); let o = wai(&d, &["enrolment", "create", &org, "--role", "relay", "--key", "org.key", "--out", "self.wfe"]); assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(2), Some("enrolment_invalid"))); // Usage. assert_eq!(wai(&d, &["enrolment", "create", NODE, "--key", "org.key"]).status.code(), Some(2), "no role"); assert_eq!(wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "absent.key"]).status.code(), Some(5), "no key file"); } #[test] fn enrolment_create_against_a_service() { let d = tmp("enrol-target"); std::fs::write(d.join("org.key"), [7u8; 32]).unwrap(); let (url, rx) = service(vec![(201, json!({"node": NODE, "role": "relay", "digest": "00"})), (422, json!({"outcome": "refuse", "reason": "enrolment_duplicate"}))]); // A service is not changed without --yes; a dry run names the request and sends nothing. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url]); assert_eq!(o.status.code(), Some(4)); assert_eq!(envelope(&o)["proposed"][0]["request"], "POST /v1/fleet/enrolments"); let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--dry-run"]); assert_eq!((o.status.code(), envelope(&o)["plan"][0]["request"].as_str()), (Some(0), Some("POST /v1/fleet/enrolments"))); let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--yes"]); assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stdout)); let (line, body) = rx.recv().unwrap(); assert_eq!(line, "POST /v1/fleet/enrolments HTTP/1.0"); let sent = wai::fleet::unhex(serde_json::from_str::(&body).unwrap()["enrolment"].as_str().unwrap()).unwrap(); assert!(wai::fleet::Enrolment::parse(&sent).is_ok(), "the service is given a signed enrolment"); assert!(!d.join(format!("{}.wfe", &NODE[..16])).exists(), "with --target, a file only with --out"); // The service's refusal is named by the fleet's reason. let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "org.key", "--target", &url, "--yes"]); assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(1), Some("enrolment_duplicate"))); // No service there: unavailable, and worth a retry. let o = wai(&d, &["enrolment", "list", "--target", "http://127.0.0.1:9"]); let e = envelope(&o); assert_eq!((o.status.code(), e["error"]["code"].as_str(), e["error"]["retryable"].as_bool()), (Some(6), Some("target-unavailable"), Some(true))); } #[test] fn report_create_and_export_and_verify() { let d = tmp("report"); let week = vector("a_week"); let digest = week["outcome"]["digest"].as_str().unwrap().to_string(); let (start, end) = (week["outcome"]["start"].to_string(), week["outcome"]["end"].to_string()); let mut altered = week["bundle"].clone(); let mut r = wai::fleet::unhex(altered["report"].as_str().unwrap()).unwrap(); let n = r.len() - 1; r[n] ^= 1; altered["report"] = json!(wai::fleet::hex(&r)); let (url, rx) = service(vec![ (201, json!({"report": digest, "outcome": week["outcome"], "bundle": week["bundle"]})), (200, json!({"report": digest, "outcome": week["outcome"], "bundle": week["bundle"]})), (200, json!({"report": digest, "outcome": week["outcome"], "bundle": altered})), ]); assert_eq!(wai(&d, &["report", "create", "--target", &url, "--start", &start]).status.code(), Some(2), "no end"); let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--dry-run"]); assert_eq!((o.status.code(), envelope(&o)["dry_run"].as_bool()), (Some(0), Some(true))); assert_eq!(envelope(&o)["plan"][0]["request"], "POST /v1/fleet/reports"); assert_eq!(wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end]).status.code(), Some(4)); let o = wai(&d, &["report", "create", "--target", &url, "--start", &start, "--end", &end, "--yes"]); assert_eq!(o.status.code(), Some(0)); assert_eq!(envelope(&o)["data"]["outcome"]["totals"]["energy"]["hops"]["coverage"], "partial"); assert_eq!(rx.recv().unwrap().0, "POST /v1/fleet/reports HTTP/1.0"); // Exported: verified here, then written. let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json"]); assert_eq!(o.status.code(), Some(0), "{}", String::from_utf8_lossy(&o.stdout)); assert_eq!(rx.recv().unwrap().0, format!("GET /v1/fleet/reports/{digest} HTTP/1.0")); let written: Value = serde_json::from_str(&std::fs::read_to_string(d.join("week.wai-fleet.json")).unwrap()).unwrap(); assert_eq!(written, week["bundle"]); // A bundle the service altered is not written. let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "altered.wai-fleet.json"]); assert_eq!((o.status.code(), envelope(&o)["error"]["code"].as_str()), (Some(1), Some("bad_signature"))); assert!(!d.join("altered.wai-fleet.json").exists()); // Exported again over the file: that waits for --yes, and fetches before it asks. let o = wai(&d, &["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json", "--dry-run"]); let e = envelope(&o); assert_eq!((o.status.code(), e["plan"][1]["write"].as_str(), e["plan"][1]["replaces"].as_bool()), (Some(0), Some("week.wai-fleet.json"), Some(true)), "{e:#}"); // `wai verify` checks the bundle offline, and refuses it altered. let o = wai_text(&d, &["verify", "week.wai-fleet.json"]); let t = String::from_utf8_lossy(&o.stdout); assert!(o.status.success(), "{t}"); assert!(t.contains("every line recomputed") && t.contains("silent") && t.contains("hops partial"), "{t}"); std::fs::write(d.join("bad.wai-fleet.json"), altered.to_string()).unwrap(); let o = wai_text(&d, &["verify", "bad.wai-fleet.json"]); assert_eq!(o.status.code(), Some(1)); assert!(String::from_utf8_lossy(&o.stdout).contains("FAIL fleet report: bad_signature")); }