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