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

nordisk/monetize

crates/monetize-fast/src/lib.rs

//! **The fast test** (monetize v2.0 P1, A24 §8.2; CI lane 2026-10-23): a product's estate on
//! monetize's real code with the product's real box binaries in rootless containers, in
//! minutes, not the 45-minute KVM gate. Each product's `cargo xtask fast` builds its box
//! binaries and media and calls this library (through the `monetize-fast` binary); the xtask
//! only parses arguments.
//!
//! ```text
//!  podman's rootless network namespace (this process: the ONE side of the utility network)
//!  ┌───────────────────────────────┐        podman network "fast-<run>", the mock's utility /21
//!  │ the shop's pairing (SP)        │──WG──▶ box-front  (the product's front binary)
//!  │  nordisk_pairing::installer    │──WG──▶ box-main   (the product's server binary)
//!  │ mock-upcloud's ContainerEngine │──WG──▶ box-twin
//!  └───────────────────────────────┘
//! ```
//!
//! What is REAL today: the shop's minting of the order's material and seeds, the shop's own
//! installer (`ShopPairing::install`: the plan, the feeds, `installer::run` over each box's
//! WireGuard link, the verdict), the product's box binaries (each raises its own WireGuard
//! links: `--cap-add NET_ADMIN --device /dev/net/tun`), the election, the twin's certification
//! by the front and its follow over mutual TLS.
//!
//! ★ A33-8: the run is the real ONE box from the kit (`--kit`, [`one_box`]): the estate is laid by
//! monetize's own lay. The driver's old no-kit mode (it described the machines itself and powered
//! them on, a second lay, said a DOUBLE here) is deleted.
//!
//! Every row of the receipt is one check; the receipt is the echo bench's shape:
//! `receipt.txt`, `receipt.json` (the rows and the commit of every tree).

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use std::time::Instant;

pub mod kit;
pub mod one_box;
pub mod outer;
pub mod refeed;
pub mod walk;

use monetize_server::estate_order::lifecycle;

/// The environment variable the binary sets when it re-runs itself inside podman's rootless
/// network namespace.
pub const IN_NETNS: &str = "MONETIZE_FAST_IN_NETNS";

/// The environment variable the outer binary hands the run its label in (its containers carry
/// `mock-upcloud-container=<label>`), so the outer can look for them after the teardown.
pub const RUN_LABEL: &str = "MONETIZE_FAST_RUN_LABEL";

/// This run's label: the one the outer binary chose, else `fast<pid>`.
pub fn run_label() -> String {
    std::env::var(RUN_LABEL).ok().filter(|l| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or_else(|| format!("fast{}", std::process::id()))
}

/// What a run is asked to do.
#[derive(Debug, Clone)]
pub struct Config {
    /// The product named (`--product`): any with an estate lifecycle ([`facts_of`]).
    pub product: String,
    /// The receipt's directory.
    pub out: PathBuf,
    /// The product's box binaries, mounted at `/opt/bin`.
    pub bin_dir: PathBuf,
    /// The product's media, by the hostname prefix of the machines that boot them.
    pub media: Vec<(String, PathBuf)>,
    /// The image the boxes run in (the products' musl binaries run in any).
    pub image: String,
    /// The installer's budget.
    pub budget_s: u64,
    /// The trees the receipt names (`name` → checkout).
    pub trees: Vec<(String, PathBuf)>,
    /// **The late twin** (the KVM gate's `utility-dhcp-late-first-boot` for the twin, A12): the
    /// twin's machine powers on this many seconds after the others, so its offer reaches the
    /// installer after the front's verdict. 0: every machine at once.
    pub twin_late_s: u64,
    /// **The real ONE box** ([`one_box`]): the kit's directory. `None`: the driver pairs the
    /// estate itself with the shop's installer in this process (the inner ring).
    pub kit: Option<PathBuf>,
    /// The utility network's `X.Y` (of `X.Y.8.0/21`): one the HOST does not hold
    /// (`mock_upcloud::container::free_utility_here`, read before the run enters the
    /// rootless network namespace). Its gateway `X.Y.15.254` is this run, the ONE side.
    pub utility: String,
    /// **The parallel slot** (0..[`mock_upcloud::container::FAST_SLOTS`]) this run holds: its
    /// utility network, its ports ([`one_box::ports_of`]) and its own work directory.
    pub slot: u8,
}

/// The run's work directory under `out`: slot 0 keeps `work`, slot `n` has `work-slot<n>`, so two
/// runs of one product in one checkout never wipe each other's.
pub fn work_dir_of(slot: u8) -> String {
    if slot == 0 { "work".into() } else { format!("work-slot{slot}") }
}

impl Default for Config {
    fn default() -> Self {
        Config {
            product: String::new(),
            out: PathBuf::from("target/fast"),
            bin_dir: PathBuf::new(),
            media: Vec::new(),
            image: "docker.io/library/alpine:3".into(),
            budget_s: 180,
            trees: Vec::new(),
            twin_late_s: 0,
            kit: None,
            utility: mock_upcloud::container::MOCK_UTILITY.into(),
            slot: 0,
        }
    }
}

/// One receipt row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
    pub step: String,
    pub pass: bool,
    pub words: String,
    pub ms: u128,
}

/// The receipt, in the echo bench's shape.
#[derive(Default)]
pub struct Receipt {
    rows: Mutex<Vec<Row>>,
    began: Mutex<Option<Instant>>,
}

impl Receipt {
    /// Record one step: `PASS` or `FAIL` with the words. Answers whether it passed.
    pub fn step(&self, step: &str, r: Result<String, String>) -> bool {
        let now = Instant::now();
        let ms = {
            let mut b = self.began.lock().unwrap();
            let ms = b.map(|t| now.duration_since(t).as_millis()).unwrap_or(0);
            *b = Some(now);
            ms
        };
        let (pass, words) = match r {
            Ok(w) => (true, w),
            Err(w) => (false, w),
        };
        println!("fast: {} {step} ({ms} ms): {words}", if pass { "PASS" } else { "FAIL" });
        self.rows.lock().unwrap().push(Row { step: step.into(), pass, words, ms });
        pass
    }

    /// Start the clock of the first row.
    pub fn start(&self) {
        *self.began.lock().unwrap() = Some(Instant::now());
    }

    pub fn rows(&self) -> Vec<Row> {
        self.rows.lock().unwrap().clone()
    }

    pub fn passed(&self) -> bool {
        let r = self.rows.lock().unwrap();
        !r.is_empty() && r.iter().all(|r| r.pass)
    }

    /// `receipt.txt` and `receipt.json` in `out`.
    pub fn write(&self, out: &Path, product: &str, trees: &BTreeMap<String, String>) -> std::io::Result<PathBuf> {
        std::fs::create_dir_all(out)?;
        let rows = self.rows();
        let pass = rows.iter().filter(|r| r.pass).count();
        let mut t = format!("fast test receipt: {product}\n");
        for (name, head) in trees {
            t.push_str(&format!("{name} {head}\n"));
        }
        t.push_str(&format!("result: {} ({pass} of {} steps passed)\n\n", if self.passed() { "PASS" } else { "FAIL" }, rows.len()));
        for r in &rows {
            t.push_str(&format!("{} {} ({} ms): {}\n", if r.pass { "PASS" } else { "FAIL" }, r.step, r.ms, r.words));
        }
        let p = out.join("receipt.txt");
        std::fs::write(&p, t)?;
        let json = serde_json::json!({
            "product": product,
            "trees": trees,
            "pass": self.passed(),
            "rows": rows.iter().map(|r| serde_json::json!({"step": r.step, "pass": r.pass, "words": r.words, "ms": r.ms as u64})).collect::<Vec<_>>(),
        });
        std::fs::write(out.join("receipt.json"), serde_json::to_vec_pretty(&json)?)?;
        Ok(p)
    }
}

/// The HEAD of a checkout (`unknown` when it cannot be read), and how many tracked files differ from it.
pub fn head_of(dir: &Path) -> String {
    let git = |args: &[&str]| Command::new("git").arg("-C").arg(dir).args(args).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
    let Some(head) = git(&["rev-parse", "HEAD"]) else { return "unknown".into() };
    // A tree with changes git has not committed ran code no commit names: said, not hidden.
    match git(&["status", "--porcelain", "--untracked-files=no"]).map(|s| s.lines().count()) {
        Some(0) => head,
        Some(n) => format!("{head} +{n} uncommitted"),
        None => format!("{head} (its status could not be read)"),
    }
}

/// Is this process on the utility bridge (inside podman's rootless network namespace), at
/// `gateway`?
pub fn on_the_utility_bridge(gateway: &str) -> bool {
    std::fs::read_to_string("/proc/net/fib_trie").map(|t| t.contains(&format!(" {gateway}\n")) || t.contains(&format!(" {gateway}/"))).unwrap_or(false)
}


/// One laid machine of the run.
#[derive(Debug, Clone)]
pub struct Laid {
    pub role: &'static str,
    pub server: String,
    pub hostname: String,
    pub utility: String,
    pub mac: String,
    /// Its plan's shape (the product's: the twin is the weaker box, which the election picks).
    pub cores: u32,
    pub mem_mb: u32,
    /// Its public address: TEST-NET, its own (the installer knows a VM by its address), reached
    /// by nobody — the shop reaches the estate over its utility network.
    pub public: String,
}

/// Pure: a plan's size, `(cores, MiB)`: `STARTER-1xCPU-2GB` → `(1, 2048)`, `8xCPU-32GB` →
/// `(8, 32768)`.
pub fn plan_size(plan: &str) -> Result<(u32, u32), String> {
    let mut parts = plan.rsplit('-');
    let (mem, cpu) = (parts.next().unwrap_or_default(), parts.next().unwrap_or_default());
    let cores = cpu.strip_suffix("xCPU").and_then(|n| n.parse::<u32>().ok());
    let gb = mem.strip_suffix("GB").and_then(|n| n.parse::<u32>().ok());
    match (cores, gb) {
        (Some(c), Some(g)) if c > 0 && g > 0 => Ok((c, g * 1024)),
        _ => Err(format!("[fast-facts] the plan {plan:?} names no `<n>xCPU-<n>GB` size")),
    }
}

/// One machine of a product's estate as its facts name it: the hostname prefix (its shape's
/// hostname without `{company}`) and its plan's size.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MachineFacts {
    pub prefix: String,
    pub cores: u32,
    pub mem_mb: u32,
}

/// **What the fast test reads of a product** (A28 H14, lane N3): its front, main and twin, and
/// the media kinds its estate boots with the machines that boot each, all from the product's
/// lifecycle ([`lifecycle::find`]: the one its signed guest's `lifecycle.facts` registered, see
/// [`one_box::follow_guest`], else the server's native one). monetize-fast names no product.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EstateFacts {
    pub front: MachineFacts,
    pub main: MachineFacts,
    pub twin: MachineFacts,
    /// `(medium kind, hostname prefix of the machines it boots)`: the front's medium, the app
    /// boxes' medium, the keep medium and the front's keep medium, in that order.
    pub media: Vec<(String, String)>,
}

/// Pure: [`EstateFacts`] of a lifecycle.
pub fn facts_of_lifecycle(life: &dyn lifecycle::EstateLifecycle) -> Result<EstateFacts, String> {
    let kind = life.kind().to_string();
    let shape = life.default_shape().ok_or_else(|| format!("[fast-facts] `{kind}` names no default shape: the fast test lays the shape the product names"))?;
    let machine = |role: &str, m: &monetize_server::estate_order::MachineShape| -> Result<MachineFacts, String> {
        let prefix = m.hostname.strip_suffix("{company}").filter(|p| !p.is_empty()).ok_or_else(|| format!("[fast-facts] `{kind}`'s {role} hostname {:?} does not end in `{{company}}`", m.hostname))?;
        let (cores, mem_mb) = plan_size(&m.plan).map_err(|e| format!("{e} (`{kind}`'s {role})"))?;
        Ok(MachineFacts { prefix: prefix.to_string(), cores, mem_mb })
    };
    let (front, main, twin) = (machine("front", &shape.front)?, machine("main", &shape.main)?, machine("twin", &shape.twin)?);
    let u = life.update_plan();
    let mut media: Vec<(String, String)> = Vec::new();
    for (k, prefix) in [(u.front_medium.clone(), &front.prefix), (life.medium_kind("main"), &main.prefix), (u.keep_medium.clone(), &main.prefix), (u.front_keep_medium.clone(), &front.prefix)] {
        if !k.is_empty() && !media.iter().any(|(have, _)| *have == k) {
            media.push((k, prefix.clone()));
        }
    }
    Ok(EstateFacts { front, main, twin, media })
}

/// [`EstateFacts`] of `product`, refused by name when it has no lifecycle.
pub fn facts_of(product: &str) -> Result<EstateFacts, String> {
    let life = lifecycle::find(product).ok_or_else(|| format!("[fast-product] `{product}` has no estate lifecycle (no guest's lifecycle.facts registered for it): it has no fast estate"))?;
    facts_of_lifecycle(life.as_ref())
}

/// Pure over the product's facts: **the estate a product lays**, as mock-upcloud addresses it:
/// front, main and twin across both utility prefixes (the route between them is the classless
/// static route). The names and sizes are the product's ([`facts_of`]); the addresses the run's.
pub fn estate_of(product: &str, company: &str, utility: &str) -> Result<Vec<Laid>, String> {
    let f = facts_of(product)?;
    let m = |role, server: &str, mf: &MachineFacts, u: &str, mac: &str, public: &str| Laid {
        role,
        server: server.into(),
        hostname: format!("{}{company}", mf.prefix),
        utility: format!("{utility}.{u}"),
        mac: mac.into(),
        public: public.into(),
        cores: mf.cores,
        mem_mb: mf.mem_mb,
    };
    Ok(vec![
        m("front", "f0f0f0f0-front", &f.front, "8.97", "02:00:0d:08:00:61", "198.51.100.10"),
        m("main", "a1a1a1a1-main0", &f.main, "8.98", "02:00:0d:08:00:62", "198.51.100.11"),
        m("twin", "b2b2b2b2-twin0", &f.twin, "12.99", "02:00:0d:0c:00:63", "198.51.100.12"),
    ])
}


pub fn tail(s: &str, n: usize) -> String {
    let l: Vec<&str> = s.lines().collect();
    l[l.len().saturating_sub(n)..].join("\n")
}


/// **One run.** Answers the receipt's path; `Err` only when no receipt could be written.
/// ★ A33-8: there is ONE way: the real ONE box from the kit ([`one_box::run`], `--kit`), which
/// lays the estate through monetize's own lay. The driver's own no-kit mode, which described the
/// machines itself and powered them on (a second lay), is deleted: a run with no kit is refused.
pub fn run(cfg: &Config) -> Result<(PathBuf, bool), String> {
    let Some(dir) = &cfg.kit else {
        return Err(NO_KIT.into());
    };
    let kit = one_box::Kit::from_dir(dir)?;
    one_box::run(cfg, &kit)
}

/// The refusal of a run with no kit (A33-8).
pub const NO_KIT: &str = "[fast-kit] a fast run needs the kit (--kit <dir>): the estate is laid by the real ONE box, through monetize's one lay; there is no driver-side lay";

/// Is the podman network `name` still there? (`None`: podman could not say.)
pub fn network_left(name: &str) -> Option<bool> {
    let st = Command::new("podman").args(["network", "exists", name]).status().ok()?;
    match st.code() {
        Some(0) => Some(true),
        Some(1) => Some(false),
        _ => None,
    }
}

/// Pure: **the terminate step's verdict** from the bounded teardown's report and whether the
/// run's network is left. Green only when the list was read, no container is left and the
/// network is gone; the words name each step and its time either way.
pub fn terminate_verdict(swept: &mock_upcloud::container::SweepReport, network: Option<bool>) -> Result<String, String> {
    let words = swept.words();
    match (swept.left, network) {
        (Some(0), Some(false)) => Ok(words),
        (Some(0), Some(true)) => Err(format!("the run's network is left; {words}")),
        (Some(0), None) => Err(format!("podman could not say whether the run's network is left; {words}")),
        (Some(n), _) => Err(format!("{n} container(s) left; {words}")),
        (None, _) => Err(format!("the run's containers could not be listed; {words}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    /// ★ A33-8: a run with no kit is refused by name, before anything is laid or powered on.
    #[test]
    fn a_run_with_no_kit_is_refused_by_name() {
        let e = run(&Config { product: "gunnar".into(), ..Config::default() }).unwrap_err();
        assert_eq!(e, NO_KIT);
    }

    fn swept(left: Option<usize>) -> mock_upcloud::container::SweepReport {
        use mock_upcloud::container::{Sweep, SweepReport, SweepStep};
        let step = |action, secs| SweepStep { action, containers: 1, took: Duration::from_secs(secs), ok: true };
        SweepReport { steps: vec![step(Sweep::Stop, 5), step(Sweep::Kill, 1), step(Sweep::Remove, 1)], left, took: Duration::from_secs(38) }
    }

    /// ★ **terminate is green only when nothing of the run is left, and says each step's time**
    /// (2026-10-25): a container truly left after the bounded sweep, a network left, or a list
    /// that could not be read all fail the step by name; none is counted as 0.
    #[test]
    fn the_terminate_verdict_never_masks_a_container_or_network_left() {
        let ok = terminate_verdict(&swept(Some(0)), Some(false)).expect("clean");
        assert_eq!(ok, "Stop 1 in 5.0 s; Kill 1 in 1.0 s; Remove 1 in 1.0 s; 0 left after 38.0 s");
        assert!(terminate_verdict(&swept(Some(1)), Some(false)).unwrap_err().starts_with("1 container(s) left; Stop 1 in 5.0 s"));
        assert!(terminate_verdict(&swept(Some(0)), Some(true)).unwrap_err().starts_with("the run's network is left"));
        assert!(terminate_verdict(&swept(Some(0)), None).is_err(), "podman could not say: not green");
        assert!(terminate_verdict(&swept(None), Some(false)).unwrap_err().starts_with("the run's containers could not be listed"));
    }


    #[test]
    fn the_receipt_is_the_benchs_shape_with_every_trees_commit() {
        let r = Receipt::default();
        r.start();
        r.step("one", Ok("fine".into()));
        r.step("two", Err("broken".into()));
        let dir = std::env::temp_dir().join(format!("monetize-fast-{}", std::process::id()));
        let trees = BTreeMap::from([("holger".to_string(), "abc".to_string())]);
        let p = r.write(&dir, "holger", &trees).unwrap();
        let t = std::fs::read_to_string(p).unwrap();
        assert!(t.starts_with("fast test receipt: holger\nholger abc\nresult: FAIL (1 of 2 steps passed)"), "{t}");
        let j: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("receipt.json")).unwrap()).unwrap();
        assert_eq!((j["pass"].as_bool(), j["rows"][1]["step"].as_str(), j["trees"]["holger"].as_str()), (Some(false), Some("two"), Some("abc")));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn holgers_estate_is_laid_across_both_utility_prefixes_and_an_unknown_product_is_refused() {
        // The rules the natives had, as the tests keep them (v2.0 P2 12.4: no native lifecycle).
        monetize_server::testing::lifecycles::install();
        let e = estate_of("holger", "acme", "10.13").unwrap();
        assert_eq!(e.iter().map(|l| (l.role, l.hostname.as_str())).collect::<Vec<_>>(), vec![("front", "holger-web-ui-acme"), ("main", "holger-server-acme"), ("twin", "holger-twin-acme")]);
        assert!(e[2].utility.starts_with("10.13.12."), "the twin is in the other /22: {}", e[2].utility);
        let publics: std::collections::BTreeSet<&str> = e.iter().map(|l| l.public.as_str()).collect();
        assert_eq!(publics.len(), 3, "the installer knows a VM by its address: each has its own");
        assert_eq!(estate_of("holger", "acme", "10.213").unwrap()[0].utility, "10.213.8.97", "another /21 where the host holds the mock's");
        assert!(estate_of("zork", "acme", "10.13").unwrap_err().contains("[fast-product]"));
    }

    #[test]
    fn a_plans_size_is_its_cores_and_memory() {
        assert_eq!(plan_size("STARTER-1xCPU-2GB"), Ok((1, 2048)));
        assert_eq!(plan_size("8xCPU-32GB"), Ok((8, 32768)));
        assert!(plan_size("DEV-TINY").unwrap_err().contains("[fast-facts]"));
        assert!(plan_size("0xCPU-1GB").is_err());
    }

    #[test]
    fn holgers_facts_are_its_lifecycles_shape_and_media() {
        // The rules the natives had, as the tests keep them (v2.0 P2 12.4: no native lifecycle).
        monetize_server::testing::lifecycles::install();
        let f = facts_of("holger").unwrap();
        assert_eq!([&f.front, &f.main, &f.twin].map(|m| m.prefix.as_str()), ["holger-web-ui-", "holger-server-", "holger-twin-"]);
        assert!(f.twin.mem_mb < f.main.mem_mb, "the twin is the weaker box, which the election picks");
        assert_eq!(f.media.len(), 4);
    }

    #[test]
    fn gunnars_estate_is_read_from_its_lifecycle_and_not_refused() {
        // The rules the natives had, as the tests keep them (v2.0 P2 12.4: no native lifecycle).
        monetize_server::testing::lifecycles::install();
        let e = estate_of("gunnar", "acme", "10.13").expect("Gunnar's lifecycle names its estate");
        assert_eq!(e.iter().map(|l| (l.role, l.hostname.as_str())).collect::<Vec<_>>(), vec![("front", "gunnar-front-acme"), ("main", "gunnar-appliance-acme"), ("twin", "gunnar-twin-acme")]);
        // Gunnar's Mini: a 2-core 4 GB front, a 4-core 8 GB main, a 1-core 4 GB twin.
        assert_eq!(e.iter().map(|l| (l.cores, l.mem_mb)).collect::<Vec<_>>(), vec![(2, 4096), (4, 8192), (1, 4096)]);
    }
}