crates/mock-upcloud/kvm-guest/src/bin/mock_upcloud_kvm.rs
//! **`mock-upcloud-kvm` — the mock, with a real machine behind every server.**
//!
//! The same HTTP server as `mock-upcloud`, with [`DraupnirGuest`] injected as
//! the estate's engine. `POST /1.3/server` then boots a QEMU through draupnir
//! on the machine shape UpCloud hands out (SeaBIOS, no serial console, a
//! framebuffer, the clock two hours ahead, inbound UDP dropped, the CD first),
//! and the server's `state` follows that machine: an installer that powers the
//! box off reads `stopped`.
//!
//! mock-upcloud-kvm --port 8099 --root /tmp/mock-upcloud-kvm --speed 0
//!
//! What the machines left behind — argv, frame hashes, stdout bytes, disk
//! sizes — is at `GET /mock/guest/{server-uuid}`.
//!
//! # Lifetime
//!
//! The mock owns its guests. SIGINT or SIGTERM kills every one of them before
//! the process exits. If the mock is SIGKILLed they survive it, and the NEXT
//! start refuses by name, listing each leftover QEMU, until they are gone.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use draupnir_guest::DraupnirGuest;
use mock_upcloud::{Clock, Estate, Fault, Faults, Mock};
fn parse_machine(v: Option<String>) -> Result<draupnir::MachineType, String> {
match v.as_deref() {
Some("pc") => Ok(draupnir::MachineType::Pc),
Some("q35") => Ok(draupnir::MachineType::Q35),
other => Err(format!("--machine wants pc or q35, got {other:?}")),
}
}
fn main() {
if let Err(e) = run() {
eprintln!("mock-upcloud-kvm: {e}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
// `--relay HOST:PORT`: what QEMU runs per connection for an OFFLINE guest's guestfwd —
// stdin/stdout bridged to one TCP connection on this machine's loopback.
let argv: Vec<String> = std::env::args().skip(1).collect();
if argv.first().map(String::as_str) == Some("--relay") {
let to = argv.get(1).ok_or("--relay wants HOST:PORT")?;
return draupnir_guest::relay_stdio(to).map_err(Into::into);
}
let mut offline_ports: Option<Vec<u16>> = None;
let mut port: u16 = 8099;
let mut speed: u64 = 1000;
let mut seed: Option<u64> = None;
let mut root = PathBuf::from("/tmp/mock-upcloud-kvm");
let mut frame_ms: u64 = 200;
// i440fx, as UpCloud hands out (ledger L46); q35 only when asked by name.
let mut machine = draupnir::MachineType::Pc;
let mut mtls = false;
let mut hotplug = false;
let mut utility_fabric = false;
let mut public_on_loopback = false;
let mut template_images: Vec<(String, PathBuf)> = Vec::new();
let mut lan_publish: Vec<draupnir_guest::LanPublish> = Vec::new();
let mut arm: Vec<Fault> = Vec::new();
let mut stop_held_s: Option<u64> = None;
let mut dhcp_late_s: Option<u64> = None;
let mut dhcp_late_hosts: Vec<String> = Vec::new();
let mut start_fault_n: Option<u32> = None;
let mut start_fallback_s: Option<u64> = None;
let mut installer_started_s: Option<u64> = None;
let mut max_cores: Option<u32> = None;
let mut max_mem_mb: Option<u32> = None;
let mut keep_disks = false;
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()?),
// A measured fault by name, as `mock-upcloud --arm` (e.g. stop-held-in-maintenance).
"--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(", ")
)
})?);
}
// utility-dhcp-late-first-boot's window (default 90 s) and the hostnames it holds (front,twin).
"--dhcp-late-s" => {
dhcp_late_s = Some(args.next().ok_or("--dhcp-late-s wants seconds")?.parse()?)
}
"--dhcp-late-hosts" => {
dhcp_late_hosts = args
.next()
.ok_or("--dhcp-late-hosts wants words, comma-separated")?
.split(',')
.map(|w| w.trim().to_string())
.filter(|w| !w.is_empty())
.collect()
}
"--stop-held-s" => {
stop_held_s = Some(args.next().ok_or("--stop-held-s wants seconds")?.parse()?)
}
// Behaviour 71 (start-no-capacity / start-falls-back): how many starts, how long a fall-back.
"--start-fault-n" => {
start_fault_n = Some(
args.next()
.ok_or("--start-fault-n wants a count")?
.parse()?,
)
}
"--start-fallback-s" => {
start_fallback_s = Some(
args.next()
.ok_or("--start-fallback-s wants seconds")?
.parse()?,
)
}
// Behaviour 72: an installer start reads `started` this many WALL seconds (the account: 6-8 s).
"--installer-started-s" => {
installer_started_s = Some(
args.next()
.ok_or("--installer-started-s wants seconds")?
.parse()?,
)
}
"--root" => root = PathBuf::from(args.next().ok_or("--root wants a directory")?),
// The HOST's caps (round 5.3.1): a guest's `-smp` and `-m` at most these, whatever its plan.
"--max-cores" => {
max_cores = Some(args.next().ok_or("--max-cores wants a count")?.parse()?)
}
"--max-mem-mb" => {
max_mem_mb = Some(args.next().ok_or("--max-mem-mb wants MiB")?.parse()?)
}
// Leave the disk and medium files behind on exit (they are swept by default).
"--keep-disks" => keep_disks = true,
"--frame-ms" => frame_ms = args.next().ok_or("--frame-ms wants a number")?.parse()?,
"--machine" => machine = parse_machine(args.next())?,
// Also forward eth1 :50052 (gunnar's mTLS-only control listener) on 127.0.0.1.
"--mtls" => mtls = true,
// Data disks as hot-pluggable virtio-blk-pci cards: attach/detach/resize reach the running guest.
"--hotplug" => hotplug = true,
// Every guest's eth1 on ONE utility L2 (dgram multicast), DHCP from the API's offer.
"--utility-fabric" => utility_fabric = true,
// Every server's public address is its own 127.a.b.h, serving the installer's ports (tcp/8474, udp/51820, udp/51821) there.
"--public-on-loopback" => public_on_loopback = true,
// Template boots restrict=on (no NAT out); `PORT[,PORT]` of this machine's
// loopback reachable from the guest at 10.0.2.100:PORT (e.g. the bench clock, 18453).
"--offline" => {
let v = args.next().ok_or(
"--offline wants PORT[,PORT...] (the loopback ports a guest may still reach)",
)?;
offline_ports = Some(
v.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.trim().parse::<u16>())
.collect::<Result<_, _>>()?,
);
}
// `TITLE-PREFIX=IMAGE`: a clone of that public template boots as this qcow2 cloud image.
"--template-image" => {
let v = args
.next()
.ok_or("--template-image wants TITLE-PREFIX=IMAGE")?;
let (t, p) = v
.split_once('=')
.ok_or("--template-image wants TITLE-PREFIX=IMAGE")?;
template_images.push((t.to_string(), PathBuf::from(p)));
}
// `HOSTNAME=BIND:HOSTPORT-GUESTPORT[,...]`: on-prem — that server's ports on this machine's LAN address.
"--lan-publish" => lan_publish.extend(draupnir_guest::parse_lan_publish(
&args
.next()
.ok_or("--lan-publish wants HOSTNAME=BIND:HOSTPORT-GUESTPORT[,...]")?,
)?),
"--help" | "-h" => {
println!(
"mock-upcloud-kvm [--port N] [--speed N] [--seed N] [--arm FAULT]... [--stop-held-s N] [--dhcp-late-s N] [--dhcp-late-hosts W[,W]] [--start-fault-n N] [--start-fallback-s N] [--installer-started-s N] [--root DIR] [--max-cores N] [--max-mem-mb N] [--keep-disks] [--frame-ms N] [--machine pc|q35] [--mtls] [--hotplug] [--utility-fabric] [--public-on-loopback] [--offline PORT[,PORT]] [--template-image TITLE=IMAGE]... [--lan-publish HOSTNAME=BIND:HOSTPORT-GUESTPORT[,...]]\n mock-upcloud-kvm --relay HOST:PORT (what an offline guest's guestfwd runs)\n\
the mock-upcloud API with a real QEMU (via draupnir) behind every server"
);
return Ok(());
}
other => return Err(format!("unknown argument: {other}").into()),
}
}
let (mut engine, reclaimed) = DraupnirGuest::for_estate(root.clone())?;
engine.frame_gap = Duration::from_millis(frame_ms);
engine.machine = machine;
engine.mtls = mtls;
engine.hotplug = hotplug;
engine.max_cores = max_cores;
engine.max_mem_mb = max_mem_mb;
if max_cores.is_some() || max_mem_mb.is_some() {
println!(
"mock-upcloud-kvm the HOST's caps: each guest at most {} core(s) and {} MiB; the API still reports each server's plan",
max_cores.map(|c| c.to_string()).unwrap_or_else(|| "its plan's".into()),
max_mem_mb.map(|m| m.to_string()).unwrap_or_else(|| "its plan's".into())
);
}
if let Some(ports) = offline_ports {
if !public_on_loopback {
return Err("--offline needs --public-on-loopback: only the eth0 this mock renders itself is restricted".into());
}
let relay = std::env::current_exe()?;
if relay.display().to_string().contains(',') {
return Err(format!(
"--offline: this program's path {} has a comma, which a QEMU -netdev cannot carry",
relay.display()
)
.into());
}
println!("mock-upcloud-kvm OFFLINE: a template boot has every NIC restrict=on; every guest reaches {} (a medium boot keeps UDP replies for its WireGuard link)", ports.iter().map(|p| format!("{}:{p} → 127.0.0.1:{p}", draupnir_guest::OFFLINE_HOST)).collect::<Vec<_>>().join(", "));
engine.offline = Some(draupnir_guest::Offline { relay, ports });
}
if !lan_publish.is_empty() {
if !public_on_loopback {
return Err("--lan-publish needs --public-on-loopback: it adds to the eth0 this mock renders itself".into());
}
if engine.offline.is_some() {
return Err("--lan-publish with --offline: a front the internet reaches through the router is not an offline bench; say one".into());
}
for l in &lan_publish {
println!("mock-upcloud-kvm ON-PREM: server `{}` tcp/{} served on {}:{} (the router forwards the internet here)", l.hostname, l.guest_port, l.bind, l.host_port);
}
engine.lan_publish = lan_publish;
}
for (t, p) in template_images {
println!("mock-upcloud-kvm template `{t}` boots as {}", p.display());
engine = engine.with_template_image(&t, p)?;
}
if utility_fabric {
engine = engine.with_utility_fabric()?;
if let Some((_, group)) = &engine.utility {
println!("mock-upcloud-kvm utility L2 on multicast {group} (DHCP, ARP and routing answered here)");
}
}
for r in &reclaimed {
println!("mock-upcloud-kvm reclaimed a dead run's leftover: {r}");
}
let engine = Arc::new(engine);
let engine_for_sweep = engine.clone();
let faults = match seed {
Some(s) => Faults::seeded(s),
None => Faults::none(),
};
for f in &arm {
println!(
"mock-upcloud-kvm fault ARMED by name: {} — {}",
f.name(),
f.summary()
);
faults.arm(*f);
}
let mut estate =
Estate::new(Clock::new(speed), faults, seed.unwrap_or(0)).with_engine(engine.clone());
if let Some(s) = stop_held_s {
estate.timings.stop_held_ms = s * 1000;
}
if let Some(s) = dhcp_late_s {
estate.timings.dhcp_late_first_boot_ms = s * 1000;
}
if !dhcp_late_hosts.is_empty() {
println!(
"mock-upcloud-kvm utility-dhcp-late-first-boot holds only hostnames containing: {}",
dhcp_late_hosts.join(", ")
);
estate.dhcp_late_hosts = dhcp_late_hosts;
}
if let Some(n) = start_fault_n {
estate.timings.start_fault_n = n;
}
if let Some(s) = start_fallback_s {
estate.timings.start_fallback_ms = s * 1000;
}
if let Some(s) = installer_started_s {
estate.timings.installer_started_wall_ms = s * 1000;
}
if public_on_loopback {
// One /24 of loopback per mock, named by its API port (8099 → 127.80.99.h), so two mocks on
// this box never hand out the same public address.
let (a, b) = public_loopback_net(port);
println!("mock-upcloud-kvm public addresses on 127.{a}.{b}.10-57: the installer's ports (tcp/8474, udp/51820, udp/51821) at their real numbers");
estate = estate.with_public_on_loopback(a, b);
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let killed = rt.block_on(async move {
let mock = Mock::new(estate);
let (bound, _h) = mock_upcloud::serve(mock, &format!("127.0.0.1:{port}")).await?;
println!(
"mock-upcloud-kvm listening http://127.0.0.1:{bound} speed={speed}‰ seed={} engine=draupnir machine={} hotplug={hotplug} root={} frames every {frame_ms} ms",
seed.map(|s| s.to_string()).unwrap_or_else(|| "none".into()),
machine.qemu_value(),
root.display()
);
// **Every host port this mock binds, and the setting it comes from** (round 5.3.1: two
// mocks on one host). The API port is --port; every guest forward is a free loopback port
// the kernel picks; the public addresses are 127.<--port>; the utility L2's group comes
// from --root. The one fixed port is the mTLS door, and only without --utility-fabric.
println!(
"mock-upcloud-kvm host ports: API 127.0.0.1:{bound} (--port); guest forwards on free loopback ports; {}; {}",
if public_on_loopback { let (a, b) = public_loopback_net(port); format!("public addresses 127.{a}.{b}.h (from --port)") } else { "no public addresses".to_string() },
match (mtls, utility_fabric) {
(true, false) => "the mTLS door at 127.0.0.1:50052 (fixed: a twin on its own slirp dials 10.0.2.2:50052)".to_string(),
(true, true) => "no host :50052 door (the utility L2 carries replication between the boxes)".to_string(),
(false, _) => "no mTLS door".to_string(),
}
);
// The frames. The install is 2–3 s under KVM, so the pixels are
// sampled on a clock of their own and not on API reads.
let sampler = engine.clone();
tokio::spawn(async move {
let mut t = tokio::time::interval(Duration::from_millis(frame_ms));
loop {
t.tick().await;
let s = sampler.clone();
let _ = tokio::task::spawn_blocking(move || s.sample()).await;
}
});
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
Ok::<usize, Box<dyn std::error::Error>>(engine.kill_all())
})?;
println!(
"mock-upcloud-kvm exiting: killed {killed} running guest(s); no machine outlives its mock"
);
if !keep_disks {
let (n, bytes) = engine_for_sweep.sweep_storage_files();
println!("mock-upcloud-kvm exiting: removed {n} disk/medium file(s), {} MiB — no storage outlives its mock (--keep-disks keeps them)", bytes >> 20);
}
Ok(())
}
/// The loopback /24 a mock on API port `port` hands its public addresses out
/// of: `127.<port / 100 % 256>.<port % 100>` — readable (8099 → 127.80.99)
/// and distinct for every port under 25 600.
fn public_loopback_net(port: u16) -> (u8, u8) {
(((port / 100) % 256) as u8, (port % 100) as u8)
}