open-standards/wai/wai-rs/tests/cli_fleet.rs
Transaction Science 84b630956a The fleet's operations in the catalogue, and one confirmation flag, --yes
enrolment.create, enrolment.list, report.create and report.export join the catalogue as the
other operations are: placed on the client against a fleet service, with their effect, toolset,
input and output schemas, receipt and the features they need (fleet for the two that sign or
verify). wai enrolment|report run through the door, and the fleet's command module keeps the
agent rules of dx/envelope.rs instead of its own copy of them: the envelope with --json, every
file through out.rs and every request to the service named in a dry run's plan and not sent
(out::would_send), and --yes before the service is changed or a file replaced, exit 4 off a
terminal with the proposal and the confirming command.

A catalogue entry now says when it waits for confirmation (Op::confirms: never, a file the
command line did not name, any file, the target or a file, always), and the rules refuse a
change to a target that does not wait and a read that does. The export carries it and the
agent flags (AGENT_FLAGS), from which wai --help is written: --yes is the one confirmation
flag, and --confirm, which wai conform, wai setup-agent and the fleet took, is its deprecated
spelling, still accepted, so named in help, in every confirming operation's help and in the
export. wai conform confirms through the same module. wai verify's entry names the
conformance results and fleet bundles it reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:21 -04:00

210 lines
13 KiB
Rust

//! 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::<usize>().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::<Value>(&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"));
}