crates/mock-upcloud/src/bin/mock-upcloud.rs
//! `mock-upcloud` — run the fake provider.
//!
//! mock-upcloud --port 8099 # faithful timings: 98–105 s to create a server
//! mock-upcloud --port 8099 --speed 0 # virtual time: the same order, no waiting
//! mock-upcloud --port 8099 --seed 41 # seeded fault weather, replayable from 41 alone
//! mock-upcloud --port 8099 --arm out-of-stock --arm price-transport-reset
//! mock-upcloud --self-check # calibrate: can every fault report the OPPOSITE?
//!
//! Then point the real plugin at it:
//!
//! UPCLOUD_API_BASE=http://127.0.0.1:8099 UPCLOUD_TOKEN=ucat_mock monetize-server-vetra …
//!
//! `--speed` is in thousandths (`1000` = real time) so the knob is an integer
//! and two mocks with the same knob are the same mock.
use mock_upcloud::{Clock, Estate, Fault, Faults, Mock};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut port: u16 = 8099;
let mut speed: u64 = 1000;
let mut seed: Option<u64> = None;
let mut arm: Vec<Fault> = vec![];
let mut log = false;
let mut listen = "127.0.0.1".to_string();
// `--engine container` (the fast test, A24 §8.2): a rootless container behind every server.
let mut engine: Option<String> = None;
let mut cs = mock_upcloud::container::Settings {
root: std::path::PathBuf::from("/tmp/mock-upcloud-container"),
network: String::new(),
image: "docker.io/library/alpine:3".into(),
bin_dir: std::path::PathBuf::new(),
by_hostname: Vec::new(),
label: String::new(),
engine: "podman".into(),
utility: mock_upcloud::container::MOCK_UTILITY.into(),
};
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--port" => port = args.next().ok_or("--port wants a number")?.parse()?,
"--speed" => speed = args.next().ok_or("--speed wants a number")?.parse()?,
"--seed" => seed = Some(args.next().ok_or("--seed wants a number")?.parse()?),
"--arm" => {
let n = args.next().ok_or("--arm wants a fault name")?;
arm.push(Fault::parse(&n).ok_or_else(|| {
format!(
"no such fault: {n}\nthe faults are: {}",
Fault::ALL.iter().map(|f| f.name()).collect::<Vec<_>>().join(", ")
)
})?);
}
// **The calibration, and it exits non-zero.** Every variant in
// `Fault::ALL`, driven twice — disarmed and armed — with the two
// observations printed under each other. A fault whose two runs
// observe the SAME thing is named and fails the process: it arms,
// it parses, it fires, and nothing a caller can see changes, which
// is a coverage claim the crate cannot honour. Nothing is spawned
// and no socket is bound; see `mock_upcloud::self_check`.
// Every call, in order, on stderr. See `Mock::log`.
"--log" => log = true,
"--listen" => listen = args.next().ok_or("--listen wants an address")?,
"--engine" => engine = Some(args.next().ok_or("--engine wants `container`")?),
"--root" => cs.root = args.next().ok_or("--root wants a directory")?.into(),
"--network" => cs.network = args.next().ok_or("--network wants a name")?,
"--image" => cs.image = args.next().ok_or("--image wants an image")?,
"--bin-dir" => cs.bin_dir = args.next().ok_or("--bin-dir wants a directory")?.into(),
"--label" => cs.label = args.next().ok_or("--label wants a word")?,
"--utility" => cs.utility = args.next().ok_or("--utility wants X.Y (the network is X.Y.8.0/21)")?,
"--by-hostname" => {
let v = args.next().ok_or("--by-hostname wants PREFIX=MEDIUM.json")?;
let (prefix, file) = v.split_once('=').ok_or("--by-hostname wants PREFIX=MEDIUM.json")?;
let m = mock_upcloud::container::Medium::parse(&std::fs::read(file).map_err(|e| format!("{file}: {e}"))?)?
.ok_or_else(|| format!("{file} is not a container medium (no `{}` key)", mock_upcloud::container::MAGIC))?;
cs.by_hostname.push((prefix.to_string(), m));
}
"--self-check" => {
let report = mock_upcloud::self_check();
print!("{}", report.text());
if !report.ok() {
std::process::exit(1);
}
return Ok(());
}
"--help" | "-h" => {
println!("{}", help());
return Ok(());
}
other => return Err(format!("unknown argument: {other}").into()),
}
}
let faults = match seed {
Some(s) => Faults::seeded(s),
None => Faults::none(),
};
for f in &arm {
faults.arm(*f);
}
let mut estate = Estate::new(Clock::new(speed), faults, seed.unwrap_or(0));
let mut containers: Option<std::sync::Arc<mock_upcloud::container::ContainerEngine>> = None;
match engine.as_deref() {
None => {}
Some("container") => {
if cs.label.is_empty() {
cs.label = format!("p{port}");
}
if cs.network.is_empty() {
cs.network = format!("mock-upcloud-{}", cs.label);
}
// The pool follows the network (`--utility`): a box's `--ip` must be in it.
if cs.utility != mock_upcloud::container::MOCK_UTILITY {
estate = estate.with_utility(&cs.utility);
}
let e = std::sync::Arc::new(mock_upcloud::container::ContainerEngine::new(cs));
e.ensure_network()?;
estate = estate.with_engine(e.clone());
containers = Some(e);
}
Some(other) => return Err(format!("--engine {other}: the engines are `container` (and the KVM one is mock-upcloud-kvm)").into()),
}
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
rt.block_on(async move {
let mock = if log { mock_upcloud::http::Mock::logging(estate) } else { Mock::new(estate) };
let (bound, h) = mock_upcloud::serve(mock, &format!("{listen}:{port}")).await?;
// One line, and it names the seed, because a run that cannot be replayed
// is a run that found nothing.
println!(
"mock-upcloud listening http://{listen}:{bound} speed={speed}‰ seed={} armed={}",
seed.map(|s| s.to_string()).unwrap_or_else(|| "none".into()),
if arm.is_empty() {
"stale-vnc-port (the provider's normal)".to_string()
} else {
arm.iter().map(|f| f.name()).collect::<Vec<_>>().join(",")
}
);
if let Some(e) = containers {
// A container engine's run ends with its containers and its network.
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
r = h => { r?; }
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
println!("mock-upcloud stopping: every container of the run and its network go");
e.tear_down();
} else {
h.await?;
}
Ok::<(), Box<dyn std::error::Error>>(())
})?;
Ok(())
}
/// **The help text, GENERATED from [`Fault::ALL`].**
///
/// It was a hand-written list, and a hand-written list of a thing that grows is
/// a list that lies. This one named `grant-created-field`, a fault that has
/// never existed under that name — the real one is `withhold-created-field` and
/// it does the OPPOSITE, so a person who read the help and typed what it said
/// got `no such fault` if they were lucky and the wrong behaviour if they
/// weren't — and it listed 9 of the 16 faults there were. There are 20 now.
///
/// So the faults are walked, not written: each line is `Fault::name`,
/// `Fault::seeded_rate_per_mille` (or `by name` at rate 0) and
/// `Fault::summary`, whose match is exhaustive. A new variant appears here the
/// moment it compiles, and there is no second place to forget.
fn help() -> String {
let mut out = String::from(HEAD);
let width = Fault::ALL.iter().map(|f| f.name().len()).max().unwrap_or(0);
for f in Fault::ALL {
let weather = if f.seeded_rate_per_mille() == 0 {
"by name".to_string()
} else {
format!("{}\u{2030}", f.seeded_rate_per_mille())
};
out.push_str(&format!(
" {:<width$} {:>8}{} {}\n",
f.name(),
weather,
if f.sticky() { " sticky" } else { " " },
f.summary(),
width = width,
));
}
out.push_str(TAIL);
out
}
const HEAD: &str = "\
mock-upcloud — the UpCloud API 1.3, faithfully wrong
--port <n> listen here (default 8099)
--speed <n> virtual ms per real ms, in thousandths: 1000 = faithful, 0 = instant
--seed <n> seeded fault weather; the run replays from this number alone
--arm <fault> arm one fault deterministically (repeatable)
--log print every call, in order, with each server's state, on stderr
--self-check drive every fault disarmed AND armed, print the table, exit non-zero if any
of them changes nothing a caller can see
faults — the rate column is seeded weather; `by name` means it never arrives as
weather and must be asked for with --arm, which is how this crate marks a
hypothesis or a guess rather than a measurement:
";
const TAIL: &str = "
`sticky` means the fault keeps firing once it has fired, until it is disarmed:
a retry loop must not be able to outwait it. That is what was measured.
";
#[cfg(test)]
mod tests {
use super::*;
/// **The help names every fault, and it cannot go stale.** This is the
/// regression for the `grant-created-field` line: the text used to name a
/// fault that did not exist and to omit seven that did.
#[test]
fn the_help_names_every_fault_and_invents_none() {
let h = help();
for f in Fault::ALL {
assert!(h.contains(f.name()), "{} is missing from --help:\n{h}", f.name());
assert!(h.contains(f.summary()), "{} has no line in --help", f.name());
}
// Every wire name in the faults block parses back to a fault: nothing
// is advertised that `--arm` would refuse.
for line in h.lines() {
// Exactly two spaces: that is the indent an entry has, and a
// wrapped continuation line has more.
if !line.starts_with(" ") || line.starts_with(" ") {
continue;
}
let Some(word) = line.split_whitespace().next() else { continue };
if word.starts_with("--") {
continue;
}
assert!(Fault::parse(word).is_some(), "--help advertises `{word}`, which --arm does not know");
}
assert!(!h.contains("grant-created-field"), "the fault that never existed is gone");
}
}