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

nordisk/gunnar

crates/gunnar-loadbench/src/main.rs

//! **gunnar-loadbench** — how the gunnar.rs estate behaves under a DECLARED
//! concurrency and a DECLARED request rate. See `Cargo.toml`'s header for what
//! it measures and `src/safety.rs` for what it will not touch.
//!
//! Exit codes: 0 PASS, 1 FAIL, 2 the bench could not run.

mod args;
mod engine;
mod estate;
mod http;
mod outcome;
mod safety;
mod ssh;
mod stats;
mod summary;
mod upcloud;
mod verdict;

use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use crate::verdict::Threshold;

fn main() {
    let argv: Vec<String> = std::env::args().skip(1).collect();
    let a = match args::parse(&argv) {
        Ok(a) => a,
        Err(e) if e == "HELP" => {
            println!("{}", args::help());
            return;
        }
        Err(e) => {
            eprintln!("gunnar-loadbench: {e}\n\n{}", args::help());
            std::process::exit(2);
        }
    };

    if a.from_upcloud {
        match upcloud::run_from_upcloud(&a) {
            Ok(code) => std::process::exit(code),
            Err(e) => {
                eprintln!("\nFROM-UPCLOUD  refused  {e}");
                eprintln!("The remote vantage did not run. Nothing was faked; run without --from-upcloud to measure from here.");
                std::process::exit(2);
            }
        }
    }

    match run(&a) {
        Ok(true) => std::process::exit(0),
        Ok(false) => std::process::exit(1),
        Err(e) => {
            eprintln!("gunnar-loadbench: {e}");
            std::process::exit(2);
        }
    }
}

/// Returns the PASS/FAIL verdict.
pub fn run(a: &args::Args) -> Result<bool, String> {
    let started = Instant::now();
    let deadline = started + a.deadline;

    println!("gunnar-loadbench — the gunnar.rs estate, read paths only");
    println!(
        "PLAN          {:.1} rps offered, {} in flight, {} requests per probe, {} s deadline, {} s per-request budget",
        a.rate_per_s,
        a.max_in_flight,
        a.requests_per_probe,
        a.deadline.as_secs(),
        a.request_timeout.as_secs()
    );
    println!("VANTAGE       {}", a.vantage);
    println!("SAFETY        GET only; no checkout, no entitlement write, no repo or data-set write. The front's Stripe key is live.");

    // ── preflight: say what the estate looks like before loading it ──────────
    println!("\nPREFLIGHT");
    for (name, addr) in estate::MACHINES {
        print!("  {name:<10} {addr:<17} ");
        match ssh::identify(addr, 2222, Duration::from_secs(5)) {
            Ok(ident) => println!("ssh:2222 {ident}"),
            Err(e) => println!("ssh:2222 unreachable ({})", short(&e)),
        }
    }

    let probes = estate::build(a.only.as_deref(), a.request_timeout, a.max_in_flight)?;
    let mut rows: Vec<summary::ProbeSummary> = Vec::with_capacity(probes.len() + 4);

    // ── the HTTP probes ──────────────────────────────────────────────────────
    println!("\nPROBES");
    for (spec, probe) in &probes {
        if Instant::now() >= deadline {
            println!("  {:<22} SKIPPED: the run deadline passed", spec.name);
            continue;
        }
        let plan = engine::Plan {
            n: a.requests_per_probe,
            workers: a.max_in_flight,
            rate_per_s: a.rate_per_s,
            timeout: a.request_timeout,
        };
        let r = engine::run(plan, deadline, |_, released| probe.once(released));
        let st = stats::summarise(&r.samples, r.wall_s);
        let why = verdict::judge(
            &st,
            spec.expect.key(),
            r.not_issued,
            a.requests_per_probe,
            &spec.bar,
        );
        report(spec.name, spec.about, &st, r.not_issued, &why);
        rows.push(summary::row(
            spec.name,
            spec.url,
            spec.expect.key(),
            a.requests_per_probe,
            r.not_issued,
            &st,
            why,
        ));
    }

    // ── the git SSH doors ────────────────────────────────────────────────────
    for (name, host, port) in estate::SSH_DOORS {
        if a.only.as_deref().is_some_and(|o| o != *name) {
            continue;
        }
        if Instant::now() >= deadline {
            println!("  {name:<22} SKIPPED: the run deadline passed");
            continue;
        }
        let probe = ssh::KexProbe {
            name,
            host: (*host).to_string(),
            port: *port,
            timeout: a.request_timeout,
        };
        let plan = engine::Plan {
            n: a.requests_per_probe,
            workers: a.max_in_flight,
            rate_per_s: a.rate_per_s,
            timeout: a.request_timeout,
        };
        let r = engine::run(plan, deadline, |_, released| probe.once(released));
        let st = stats::summarise(&r.samples, r.wall_s);
        let bar = Threshold::ssh_bar();
        let why = verdict::judge(&st, "ok", r.not_issued, a.requests_per_probe, &bar);
        let about = "RFC 4253 identification exchange + the server's KEXINIT. A real round trip; no Diffie-Hellman, so it is safe to point a rate at.";
        report(name, about, &st, r.not_issued, &why);
        rows.push(summary::row(
            name,
            &format!("ssh://{host}:{port}"),
            "ok",
            a.requests_per_probe,
            r.not_issued,
            &st,
            why,
        ));
    }

    // ── the real git read verb, when it was compiled in ──────────────────────
    #[cfg(feature = "git-ssh")]
    {
        let name = "git-ls-refs";
        let run_it = a.only.as_deref().map(|o| o == name).unwrap_or(true);
        if run_it && Instant::now() < deadline {
            let key = std::env::var("GUNNAR_LOADBENCH_SSH_KEY").unwrap_or_else(|_| {
                format!(
                    "{}/.ssh/id_ed25519",
                    std::env::var("HOME").unwrap_or_default()
                )
            });
            let url = std::env::var("GUNNAR_LOADBENCH_GIT_URL")
                .unwrap_or_else(|_| "ssh://git@git.gunnar.rs:2222/nordisk/gunnar".to_string());
            let probe = ssh::LsRefsProbe {
                name,
                url: url.clone(),
                key: std::path::PathBuf::from(key),
            };
            let plan = engine::Plan {
                n: a.requests_per_probe,
                workers: a.max_in_flight,
                rate_per_s: a.rate_per_s,
                timeout: a.request_timeout,
            };
            let r = engine::run(plan, deadline, |_, released| probe.once(released));
            let st = stats::summarise(&r.samples, r.wall_s);
            let bar = Threshold::git_bar();
            let why = verdict::judge(&st, "ok", r.not_issued, a.requests_per_probe, &bar);
            report(
                name,
                "protocol-v2 ls-refs over russh (gunnar_client::ls_remote). No pack is generated: a clone is deliberately not benched.",
                &st,
                r.not_issued,
                &why,
            );
            rows.push(summary::row(
                name,
                &url,
                "ok",
                a.requests_per_probe,
                r.not_issued,
                &st,
                why,
            ));
        }
    }
    #[cfg(not(feature = "git-ssh"))]
    println!(
        "\n  git-ls-refs            NOT COMPILED IN. The pure-Rust path exists (gunnar_client::ls_remote over russh);\n                         rebuild with --features git-ssh to measure a real git read verb."
    );

    // ── the verdict ──────────────────────────────────────────────────────────
    let pass = rows.iter().all(|r| r.pass) && !rows.is_empty();
    let sum = summary::Summary {
        schema: summary::SCHEMA,
        tool: format!("gunnar-loadbench {}", env!("CARGO_PKG_VERSION")),
        at: SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0),
        vantage: a.vantage.clone(),
        estate: estate::MACHINES
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect(),
        plan: summary::Plan {
            rate_per_s: a.rate_per_s,
            max_in_flight: a.max_in_flight,
            per_probe_requests: a.requests_per_probe,
            deadline_s: a.deadline.as_secs(),
            request_timeout_s: a.request_timeout.as_secs(),
        },
        probes: rows,
        pass,
    };

    let text = serde_json::to_string_pretty(&sum).map_err(|e| e.to_string())?;
    std::fs::write(&a.out, &text).map_err(|e| format!("writing {}: {e}", a.out))?;
    println!("\nSUMMARY       {}", a.out);

    if let Some(b) = &a.baseline {
        println!("\nAGAINST {b}");
        match std::fs::read_to_string(b)
            .map_err(|e| e.to_string())
            .and_then(|t| serde_json::from_str::<summary::Summary>(&t).map_err(|e| e.to_string()))
        {
            Ok(prev) => {
                for line in summary::compare(&prev, &sum) {
                    println!("  {line}");
                }
            }
            // No baseline is a fact, not a failure of the estate.
            Err(e) => println!("  no usable baseline ({})", short(&e)),
        }
    }

    println!(
        "\nVERDICT       {}  ({} probes, {:.0} s wall)",
        if pass { "PASS" } else { "FAIL" },
        sum.probes.len(),
        started.elapsed().as_secs_f64()
    );
    if !pass {
        for p in sum.probes.iter().filter(|p| !p.pass) {
            for w in &p.why {
                println!("  FAIL {:<22} {w}", p.name);
            }
        }
    }
    Ok(pass)
}

fn report(name: &str, about: &str, st: &stats::Stats, not_issued: usize, why: &[String]) {
    println!(
        "\n  {:<22} {}  n={} p50={:.0} p90={:.0} p99={:.0} max={:.0} ms  {:.2} rps  slip(p99)={:.0} ms",
        name,
        if why.is_empty() { "PASS" } else { "FAIL" },
        st.n,
        st.p50_ms,
        st.p90_ms,
        st.p99_ms,
        st.max_ms,
        st.throughput_rps,
        st.p99_late_ms
    );
    println!("  {:<22} {}", "", about);
    let reasons: Vec<String> = st
        .nonzero()
        .into_iter()
        .map(|(k, v)| format!("{k}={v}"))
        .collect();
    println!(
        "  {:<22} outcomes: {}{}",
        "",
        reasons.join(" "),
        if not_issued > 0 {
            format!("  not-issued={not_issued}")
        } else {
            String::new()
        }
    );
}

fn short(e: &str) -> String {
    e.lines().next().unwrap_or(e).chars().take(110).collect()
}