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

nordisk/monetize

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