Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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}"
        );
    }
}