xtask/src/bench.rs
//! `cargo xtask bench lake [--out <dir>] [--cloud …] [--vendor …]` — the Lake's whole lifecycle
//! on the ONE box's real code (crates/lake-product): builds the Lake guest for wasm32, runs
//! `lake-bench`, and leaves `receipt.txt`, `receipt.json` and `bench.log` (every line, the
//! guest's included) in the out dir (default `<target>/lake-bench`). Container level: no KVM,
//! no network beyond loopback. Exit 0 only when every step passed.
//!
//! The SIGNED axes (`--cloud upcloud|scaleway`, `--vendor stripe|klarna|swish`) are
//! monetize-impl's guests: this verb builds the ones chosen (and only those) in the sibling
//! `../monetize-impl` at run time — the guests for wasm32, the vendor's fake
//! (`monetize-pay-impl`'s example `fake_<vendor>_serve`) — and copies them into
//! `<out>/signed`, which `lake-bench --signed` takes. The open repo names no private code; with
//! the sibling missing a signed axis is refused by name.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The bench's default out dir.
pub fn default_out(repo_root: &Path) -> PathBuf {
crate::guests::target_dir(repo_root).join("lake-bench")
}
fn cargo() -> String {
std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())
}
/// The commit the receipt names: `git rev-parse`, else `BENCH_COMMIT` (a build box with no .git).
fn commit(repo_root: &Path) -> String {
if let Ok(c) = std::env::var("BENCH_COMMIT") {
if !c.trim().is_empty() {
return c.trim().to_string();
}
}
Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.current_dir(repo_root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".into())
}
/// What `cargo xtask bench lake` is asked: the receipt's dir, the cloud and the vendor.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Args {
pub out: Option<PathBuf>,
/// `mock` (the in-process MockCloud), `sandbox` (the open dummy, declared prices), or a
/// signed cloud guest: `upcloud` (against mock-upcloud), `scaleway` (against mock-scaleway).
pub cloud: String,
/// `mock` (the in-process test vendor), `sandbox` (the open dummy, pays on the spot), or a
/// signed payment guest against its fake: `stripe`, `klarna`, `swish`.
pub vendor: String,
}
/// The clouds and vendors of the bench: the open dummies, then the signed guests.
pub const CLOUDS: [&str; 4] = ["mock", "sandbox", "upcloud", "scaleway"];
pub const VENDORS: [&str; 5] = ["mock", "sandbox", "stripe", "klarna", "swish"];
/// The axes that are monetize-impl's signed guests, built from the sibling at run time.
pub const SIGNED: [&str; 5] = ["upcloud", "scaleway", "stripe", "klarna", "swish"];
/// The pairs `cargo xtask ci` benches on the signed guests (a full run, the sibling present):
/// each signed cloud and each card vendor. Swish is not in it yet: its page is the Swish app
/// (`swish://`), which a buy relay never sends a browser to, and it takes SEK only, which no
/// signed cloud prices in (the core sells in the provider's currency, with no conversion).
pub const SIGNED_MATRIX: [(&str, &str); 3] = [
("upcloud", "stripe"),
("scaleway", "klarna"),
("upcloud", "klarna"),
];
/// The signed axes `args` chose, the cloud first.
pub fn signed_axes(args: &Args) -> Vec<&str> {
[args.cloud.as_str(), args.vendor.as_str()]
.into_iter()
.filter(|a| SIGNED.contains(a))
.collect()
}
/// The sibling `../monetize-impl` the signed axes are built from, refused by name when it is not
/// checked out beside monetize.
pub fn sibling(repo_root: &Path, args: &Args) -> Result<PathBuf, String> {
let dir = repo_root.join("../monetize-impl");
if dir.join("Cargo.toml").is_file() {
return Ok(dir);
}
Err(format!(
"{:?} are signed guests of monetize-impl, built from the sibling ../monetize-impl at run time; it is not checked out beside monetize ({})",
signed_axes(args),
dir.display()
))
}
const USAGE: &str = "cargo xtask bench lake [--out <dir>] [--cloud mock|sandbox|upcloud|scaleway] [--vendor mock|sandbox|stripe|klarna|swish]";
/// Parse `lake [--out <dir>] [--cloud …] [--vendor …]` (`echo` is the alias of `lake` for one
/// release: the echo product became the Lake). A signed guest's name is refused with where it runs.
pub fn parse(args: &[String]) -> Result<Args, String> {
match args.first().map(String::as_str) {
Some("lake") => {}
Some("echo") => eprintln!("xtask bench: `echo` is now `lake` (the echo product became the Lake); the alias goes after one release"),
Some(other) => return Err(format!("no bench called {other:?}; the one bench is `lake`")),
None => return Err(format!("which bench? `{USAGE}`")),
}
let mut a = Args {
out: None,
cloud: "mock".into(),
vendor: "mock".into(),
};
let mut it = args[1..].iter();
while let Some(flag) = it.next() {
let value = it
.next()
.ok_or_else(|| format!("{flag} wants a value; `{USAGE}`"))?;
match flag.as_str() {
"--out" => a.out = Some(PathBuf::from(value)),
"--cloud" if CLOUDS.contains(&value.as_str()) => a.cloud = value.clone(),
"--cloud" => {
return Err(format!(
"no cloud {value:?}: the bench knows {}",
CLOUDS.join("|")
))
}
"--vendor" if VENDORS.contains(&value.as_str()) => a.vendor = value.clone(),
"--vendor" => {
return Err(format!(
"no vendor {value:?}: the bench knows {}",
VENDORS.join("|")
))
}
_ => return Err(format!("unknown argument {flag} {value}; `{USAGE}`")),
}
}
Ok(a)
}
pub fn run(repo_root: &Path, args: &Args) -> Result<PathBuf, String> {
let out = args.out.clone().unwrap_or_else(|| default_out(repo_root));
let status = Command::new(cargo())
.args([
"build",
"--release",
"--target",
crate::guests::TARGET,
"-p",
"monetize-guest-lake",
])
.current_dir(repo_root)
.status()
.map_err(|e| format!("running cargo: {e}"))?;
if !status.success() {
return Err(format!("the Lake guest did not build: {status}"));
}
let wasm = crate::guests::target_dir(repo_root)
.join(crate::guests::module_rel_path("monetize-guest-lake"));
if !wasm.is_file() {
return Err(format!("{} is missing after the build", wasm.display()));
}
let signed = if signed_axes(args).is_empty() {
None
} else {
Some(build_signed(repo_root, args, &out)?)
};
// Both streams into `bench.log` as well as the terminal: the ONE box's own lines and the
// guest's (`[guest lake] lake: guest op …`, the host's log door) are on stderr.
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
let mut child = Command::new(cargo())
.args([
"run",
"-q",
"-p",
"lake-product",
"--bin",
"lake-bench",
"--",
])
.args(["--cloud", &args.cloud, "--vendor", &args.vendor])
.args(
signed
.iter()
.flat_map(|d| [std::ffi::OsStr::new("--signed"), d.as_os_str()]),
)
.arg(&wasm)
.arg(&out)
.arg(commit(repo_root))
.current_dir(repo_root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("running lake-bench: {e}"))?;
let log = std::sync::Arc::new(std::sync::Mutex::new(
std::fs::File::create(out.join("bench.log")).map_err(|e| e.to_string())?,
));
let pump = |r: Box<dyn std::io::Read + Send>,
log: std::sync::Arc<std::sync::Mutex<std::fs::File>>| {
std::thread::spawn(move || {
use std::io::{BufRead, Write};
for line in std::io::BufReader::new(r).lines().map_while(Result::ok) {
println!("{line}");
let _ = writeln!(log.lock().unwrap(), "{line}");
}
})
};
let a = pump(Box::new(child.stdout.take().expect("piped")), log.clone());
let b = pump(Box::new(child.stderr.take().expect("piped")), log);
let status = child.wait().map_err(|e| format!("lake-bench: {e}"))?;
let _ = (a.join(), b.join());
let receipt = out.join("receipt.txt");
if !status.success() {
return Err(format!(
"the Lake bench FAILED ({status}); see {}",
receipt.display()
));
}
Ok(receipt)
}
/// **The signed axes, built from the sibling** (only those chosen): the guests for wasm32
/// release, the vendor's fake, copied into `<out>/signed` as `lake-bench --signed` reads them
/// (`<axis>.wasm`, `fake_<vendor>_serve`, Swish's test certificate `swish-client.pem`). Each
/// cargo call has its own target dir, `<target>/bench-monetize-impl`.
fn build_signed(repo_root: &Path, args: &Args, out: &Path) -> Result<PathBuf, String> {
let impl_dir = sibling(repo_root, args)?;
let target = crate::guests::target_dir(repo_root).join("bench-monetize-impl");
let w = crate::guests::TARGET;
let cargo_in = |step: &str, a: &[&str]| -> Result<(), String> {
println!(
"── bench: {step} (in {}, target {})",
impl_dir.display(),
target.display()
);
let status = Command::new(cargo())
.args(a)
.current_dir(&impl_dir)
.env("CARGO_TARGET_DIR", &target)
.status()
.map_err(|e| format!("{step}: running cargo: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{step}: cargo {} failed: {status}", a.join(" ")))
}
};
let axes = signed_axes(args);
let krates: Vec<String> = axes.iter().map(|a| format!("monetize-guest-{a}")).collect();
// Not `--locked`: the sibling's lock follows monetize's versions (a crate monetize bumped
// is a version line in it, lock-drift rule 2026-10-23), as the trials build Holger's guest.
let mut build = vec!["build", "--release", "--target", w];
for k in &krates {
build.extend(["-p", k.as_str()]);
}
cargo_in(&format!("the signed guests {axes:?}"), &build)?;
let vendor = SIGNED[2..]
.contains(&args.vendor.as_str())
.then_some(args.vendor.as_str());
let example = vendor.map(|v| format!("fake_{v}_serve"));
if let (Some(v), Some(ex)) = (vendor, &example) {
let features = if v == "stripe" {
"stripe,testing"
} else {
"testing"
};
cargo_in(
&format!("the fake {v} ({ex})"),
&[
"build",
"-p",
"monetize-pay-impl",
"--features",
features,
"--example",
ex,
],
)?;
}
let dir = out.join("signed");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let copy = |from: PathBuf, to: &str| -> Result<(), String> {
if !from.is_file() {
return Err(format!("{} is missing after its build", from.display()));
}
std::fs::copy(&from, dir.join(to))
.map(|_| ())
.map_err(|e| format!("{} -> {to}: {e}", from.display()))
};
for (a, k) in axes.iter().zip(&krates) {
copy(
target
.join(w)
.join("release")
.join(format!("{}.wasm", k.replace('-', "_"))),
&format!("{a}.wasm"),
)?;
}
if let Some(ex) = &example {
copy(target.join("debug/examples").join(ex), ex)?;
}
if vendor == Some("swish") {
copy(
impl_dir.join("guests/swish/tests/fixtures/test-client-NOT-A-SECRET.pem"),
"swish-client.pem",
)?;
}
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_one_bench_is_the_lake_echo_is_its_alias_and_the_axes_are_the_dummies_and_the_signed_guests(
) {
let a = |v: &[&str]| parse(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>());
assert_eq!(
a(&["lake"]).unwrap(),
Args {
out: None,
cloud: "mock".into(),
vendor: "mock".into()
}
);
assert_eq!(
a(&["echo"]).unwrap().out,
None,
"the alias, for one release"
);
assert_eq!(
a(&["lake", "--out", "/tmp/x", "--cloud", "sandbox", "--vendor", "sandbox"]).unwrap(),
Args {
out: Some(PathBuf::from("/tmp/x")),
cloud: "sandbox".into(),
vendor: "sandbox".into()
}
);
assert!(a(&[]).is_err());
assert!(a(&["kvm"]).is_err());
assert!(a(&["lake", "--fast"]).is_err());
// The signed guests are axes of the bench (built from the sibling ../monetize-impl).
for (cloud, vendor) in [
("upcloud", "stripe"),
("scaleway", "klarna"),
("upcloud", "swish"),
("mock", "klarna"),
("scaleway", "sandbox"),
] {
assert_eq!(
a(&["lake", "--cloud", cloud, "--vendor", vendor]).unwrap(),
Args {
out: None,
cloud: cloud.into(),
vendor: vendor.into()
}
);
}
let e = a(&["lake", "--cloud", "aws"]).unwrap_err();
assert!(e.contains("aws") && e.contains("upcloud|scaleway"), "{e}");
let e = a(&["lake", "--vendor", "paypal"]).unwrap_err();
assert!(
e.contains("paypal") && e.contains("stripe|klarna|swish"),
"{e}"
);
}
#[test]
fn only_the_signed_axes_are_built_from_the_sibling() {
let args = |c: &str, v: &str| Args {
out: None,
cloud: c.into(),
vendor: v.into(),
};
assert!(signed_axes(&args("mock", "sandbox")).is_empty());
assert_eq!(signed_axes(&args("upcloud", "mock")), ["upcloud"]);
assert_eq!(
signed_axes(&args("scaleway", "klarna")),
["scaleway", "klarna"]
);
let e = sibling(
Path::new("/nonexistent-monetize"),
&args("upcloud", "stripe"),
)
.unwrap_err();
assert!(
e.contains("../monetize-impl") && e.contains("upcloud") && e.contains("stripe"),
"the missing sibling is refused by name: {e}"
);
}
}