mock-upcloud 0.1.3

A faithful fake of the UpCloud API 1.3 — the lies included — backed by real KVM guests
Documentation
//! **The KVM half: the seam, and what a real guest has to do to fill it.**
//!
//! Behaviours 8 and 13–21 are not API behaviours. They are things a GUEST does,
//! and no amount of JSON reproduces them: a PID-1 panic that is invisible
//! except on the framebuffer is only invisible if there is a framebuffer, and an
//! installer that loops only loops if something really boots twice. So the mock
//! delegates the guest to a [`GuestEngine`], and a real one is a qemu.
//!
//! # Why the real engine is not in this crate's dependencies
//!
//! draupnir is the law's door to a hypervisor and this crate starts no qemu of
//! its own. But draupnir's live KVM backend (`backend-tunnr`) depends on
//! `tunnr-vm` by PATH, and tunnr is not published. An optional dependency is
//! still resolved into the lockfile, so naming draupnir here would make a fresh
//! clone of the open `monetize` workspace require a `tunnr` checkout beside it —
//! the exact failure this workspace already paid for with Skidbladnir and wrote
//! down in its root manifest.
//!
//! So the seam is here and the binding is not. The binding is the detached
//! `kvm-guest` crate beside this one (`DraupnirGuest`, rendered by draupnir's
//! own `draupnir::qemu`, not tunnr's argv — see the measurement below for why
//! tunnr's could not express this machine), and its `mock-upcloud-kvm` binary
//! serves this API with a real QEMU behind every server. `cargo test -p
//! mock-upcloud` needs no hypervisor: the default engine runs nothing.
//!
//! # What the real engine must do, and what each clause is for
//!
//! | clause | qemu | behaviour |
//! |---|---|---|
//! | no serial console | `-serial none` | 13 — a PID-1 panic reaches nobody |
//! | a framebuffer, and only that | `-vga std -display vnc=…` | 13 — the panic IS visible, on the pixels |
//! | SeaBIOS, never OVMF | no `-bios OVMF*`, no `pflash` | 15 — the BIOS path is the one that breaks |
//! | RTC two hours ahead | `-rtc base=<now+2h>` | 19 — the skew gunnar-clock exists for |
//! | no inbound UDP | a user-mode net with no `hostfwd` for udp | 18 — DNS and NTP are useless in there |
//! | the CD first while it is inserted | `-boot order=dc` | 17 — the loop |
//!
//! A [`GuestEngine`] that cannot honour a clause must REFUSE it by name rather
//! than quietly boot without it. A guest that silently got a serial console
//! would make behaviour 13 untestable while reporting that it had been tested,
//! which is the always-yes defect this estate has now found four times.
//!
//! # MEASURED 2026-09-20 on oden: draupnir/tunnr cannot express this machine
//!
//! `tunnr::boot_primitive` builds its argv unconditionally, and three of its
//! lines contradict three of the clauses above:
//!
//! * `args.push("-serial"); args.push("mon:stdio")` — **always**, with the
//!   comment "Serial → stdio so we capture the console and detect the BOOT-OK
//!   marker". Behaviour 13 says there is no serial console, and the marker
//!   protocol is built on there being one.
//! * `// UEFI via OVMF pflash, exactly as the design mandates.` — the firmware
//!   boot path is OVMF, unconditionally. Behaviour 15 says SeaBIOS, and UpCloud
//!   offers no knob.
//! * `if spec.headless { args.push("-nographic") }` — which removes the
//!   framebuffer entirely. Behaviour 13 says the framebuffer is the ONLY place
//!   a PID-1 panic appears.
//!
//! So the estate's KVM proofs have been run on a machine that is not the
//! machine UpCloud hands out — a UEFI box with a serial console and no screen,
//! against a BIOS box with a screen and no serial. That is a large part of why
//! an image that "booted fine locally" could panic invisibly up there.
//!
//! The faithful argv, run against the real `gunnar-installer-open-fullk.iso`
//! (59 496 448 bytes) on oden on 2026-09-20:
//!
//! ```text
//! qemu-system-x86_64 -enable-kvm -m 2048 -smp 2 -no-reboot \
//!   -bios /usr/share/seabios/bios-256k.bin \
//!   -drive file=disk.qcow2,if=virtio,format=qcow2 -cdrom <iso> -boot order=dc \
//!   -serial none -vga std -display none -vnc 127.0.0.1:91 \
//!   -rtc base=<host time + 2h> \
//!   -netdev user,id=n0,dns=10.0.2.99 -device virtio-net-pci,netdev=n0
//! ```
//!
//! What it measured: **zero bytes** on stdout across every run, and the
//! installer's whole narration present on the framebuffer — `INSTALLER:
//! payload app.znippy materialized off the ISO volume (8580685 bytes)`,
//! `policy: require_signed=NO`, and `FAILED hwdetect: no usable target disk`
//! on the run with no disk attached. Two screendumps twenty-five seconds apart
//! hashed differently, so the guest really progressed, and the qcow2 grew from
//! 91 951 104 to 93 786 112 bytes, so the install really wrote. There is no
//! log to quote for any of it. That is the point.
//!
//! `dns=10.0.2.99` is how behaviour 18 is modelled: slirp serves DNS on its own
//! `.3` and nothing else, so the guest's queries leave and no reply ever
//! arrives — which is what `systemd-timesyncd` experiences up there, and why
//! gunnar-clock syncs from the front over a signed nonce instead.
//!
//! **What tunnr would need** for draupnir to host this: a `firmware`
//! (`Bios`/`Uefi`) choice on its `BootSpec`, a `serial` (`Stdio`/`None`)
//! choice, and a headless mode that keeps `-vga std` and drops only the
//! display. Three fields. Until they exist the seam below is the honest shape,
//! and a `DraupnirGuest` would have to REFUSE `no-serial-console` and
//! `seabios` by name rather than boot without them.

use std::fmt;

/// What the mock asks a hypervisor for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GuestSpec {
    pub name: String,
    pub mem_mb: u32,
    pub cores: u32,
    /// The disk image the server boots.
    pub disk: String,
    /// The medium in the cdrom device, if any. `None` is an ejected drive.
    pub medium: Option<String>,
    /// `true` while the CD is first in the boot order — behaviour 17's whole
    /// mechanism.
    pub cdrom_first: bool,
    /// Behaviour 19.
    pub rtc_skew_seconds: i64,
}

impl GuestSpec {
    pub fn new(name: impl Into<String>, disk: impl Into<String>) -> GuestSpec {
        GuestSpec {
            name: name.into(),
            mem_mb: 2048,
            cores: 2,
            disk: disk.into(),
            medium: None,
            cdrom_first: false,
            // +2 h. Measured on every yvra VM, which is why gunnar-clock syncs
            // from the FRONT over a signed nonce and never from the internet.
            rtc_skew_seconds: 7200,
        }
    }
}

/// What a guest did. Deliberately NOT a log: behaviour 20 is that nothing
/// narrates the install window, so an engine that returned the guest's output
/// would be lying about the one thing this half exists to prove.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuestOutcome {
    /// The installer ran and the box came up on its disk. `ms` is the measured
    /// install time — 10 011 ms under KVM for korp-installer.
    Installed { ms: u64 },
    /// The installer ran again because the CD was still first. Behaviour 17.
    Looped { rounds: u32 },
    /// PID 1 died. Visible only as pixels, so `frame_sha256` is the ONLY
    /// evidence — there is no serial log to quote.
    Panicked { frame_sha256: String },
    /// The engine refused a clause of the spec by name rather than booting
    /// without it.
    Refused { clause: &'static str, why: String },
}

impl fmt::Display for GuestOutcome {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            GuestOutcome::Installed { ms } => write!(f, "installed in {ms} ms"),
            GuestOutcome::Looped { rounds } => write!(f, "installer looped {rounds} times"),
            GuestOutcome::Panicked { frame_sha256 } => {
                write!(f, "PID 1 panicked; the only evidence is the framebuffer ({frame_sha256})")
            }
            GuestOutcome::Refused { clause, why } => write!(f, "refused {clause}: {why}"),
        }
    }
}

/// **One server's machine, as the estate describes it at power-on.**
///
/// Everything is named by the ESTATE's ids — server and storage uuids — and
/// never by a path: where a disk lives is the engine's business, so this crate
/// stays free of any hypervisor and of any filesystem layout.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Machine {
    /// The server uuid.
    pub server: String,
    pub mem_mb: u32,
    pub cores: u32,
    /// The attached disks, boot disk first, each at the size its STORAGE record
    /// says now (not the size it was attached at: a resize changes the record).
    pub disks: Vec<MachineDisk>,
    /// The storage uuid in the cdrom tray, if any. `None` is an ejected drive.
    pub medium: Option<String>,
    /// `true` while the CD is first in the boot order (behaviour 17).
    pub cdrom_first: bool,
    /// Behaviour 19.
    pub rtc_skew_seconds: i64,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MachineDisk {
    pub storage: String,
    pub size_gib: u64,
}

/// **The seam.** `boot` is the one-shot question a storm asks; the lifecycle
/// methods below are what the estate calls when an API request changes a
/// server, so that behind the JSON there is a machine.
///
/// Every lifecycle method has a default that does NOTHING, and
/// [`running`](GuestEngine::running) defaults to `None` ("this engine has no
/// machine to ask"), which is how the estate knows to keep its own state
/// machine. So the default engine is exactly the mock as it was, and
/// `cargo test -p mock-upcloud` needs no hypervisor.
///
/// All calls are made with the estate's lock held and must return promptly: a
/// soft stop SENDS the ACPI event and returns, it does not wait for the guest.
pub trait GuestEngine: Send + Sync {
    fn name(&self) -> &'static str;
    fn boot(&self, spec: &GuestSpec) -> GuestOutcome;

    /// Power the machine on: create any disk that does not exist yet, sized
    /// from its storage record, grow any that is smaller, and boot. An `Err` is
    /// a refusal by name, and the estate reports the server `stopped` — a
    /// server whose machine never started must not read `started`.
    fn power_on(&self, _machine: &Machine) -> Result<(), String> {
        Ok(())
    }
    /// `hard`: kill it now. Soft: press the ACPI power button and return.
    fn power_off(&self, _server: &str, _hard: bool) {}
    /// `Some(true)` a machine is running for this server, `Some(false)` one was
    /// started and is gone (it powered itself off, or died), `None` this engine
    /// has never run one — the estate's own state machine then stands.
    fn running(&self, _server: &str) -> Option<bool> {
        None
    }
    /// Take the medium out of a running (or stopped) machine's tray.
    fn eject(&self, _server: &str) {}
    /// Where the server's console REALLY listens — a loopback host and port
    /// for a real guest's VNC. `None` (the default) leaves the estate's
    /// RFC 2606 `<zone>.vnc.mock.invalid`.
    fn console(&self, _server: &str) -> Option<(String, u16)> {
        None
    }
    /// Put the medium of `storage` into the server's tray (`cdrom/load`). A
    /// running machine gets it live; a stopped one reads it at the next
    /// power-on either way, because [`Machine::medium`] is built from the
    /// device row.
    fn load(&self, _server: &str, _storage: &str) {}
    /// A storage's size record grew. The engine grows the disk only while no
    /// running machine has it open, and otherwise at the next power-on.
    fn resize_disk(&self, _storage: &str, _size_gib: u64) -> Result<(), String> {
        Ok(())
    }
    /// The bytes an upload session received, kept as that storage's medium.
    fn store_medium(&self, _storage: &str, _bytes: &[u8]) -> Result<(), String> {
        Ok(())
    }
    /// The server is being deleted: kill its machine and drop its records.
    fn forget_server(&self, _server: &str) {}
    /// The storage is gone from the account: delete its disk and medium.
    fn forget_storage(&self, _storage: &str) {}
    /// The whole estate is being replaced (`/mock/seed`): every machine and
    /// every disk goes with it, because nothing can refer to them any more.
    fn forget_all(&self) {}
    /// What the machine left behind, for `/mock/guest/{uuid}`: argv, frame
    /// hashes, stdout bytes, disk sizes. `None` from an engine with no machines.
    fn evidence(&self, _server: &str) -> Option<serde_json::Value> {
        None
    }
}

/// **The guest as a state machine, for the runs that are not about the guest.**
///
/// It does not boot anything, and it says so in its name. Its whole value is
/// that the 100 000-run storm is about the API and the money, and paying ten
/// seconds of real qemu per iteration would make the storm a three-week job. The
/// outcomes it produces are the ones the real engine produces, in the same
/// proportions, drawn from the run's seed.
pub struct VirtualGuest {
    pub install_ms: u64,
}

impl Default for VirtualGuest {
    fn default() -> Self {
        VirtualGuest { install_ms: 10_011 }
    }
}

impl GuestEngine for VirtualGuest {
    fn name(&self) -> &'static str {
        "virtual"
    }

    fn boot(&self, spec: &GuestSpec) -> GuestOutcome {
        match (spec.cdrom_first, &spec.medium) {
            // The CD is first AND a medium is in the drive: the installer runs,
            // finishes, and the next boot finds the CD first again.
            (true, Some(_)) => GuestOutcome::Looped { rounds: 2 },
            (_, _) => GuestOutcome::Installed { ms: self.install_ms },
        }
    }
}

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

    #[test]
    fn the_cd_first_with_a_medium_in_it_is_the_loop() {
        let g = VirtualGuest::default();
        let mut s = GuestSpec::new("appliance", "/var/lib/mock/appliance.qcow2");
        s.medium = Some("/var/lib/mock/gunnar.iso".into());
        s.cdrom_first = true;
        assert!(matches!(g.boot(&s), GuestOutcome::Looped { .. }));
        // The eject is the cure: no medium, no loop, whatever the boot order.
        s.medium = None;
        assert!(matches!(g.boot(&s), GuestOutcome::Installed { .. }));
    }

    #[test]
    fn the_measured_install_is_ten_thousand_and_eleven_milliseconds() {
        let g = VirtualGuest::default();
        let s = GuestSpec::new("front", "/var/lib/mock/front.qcow2");
        assert_eq!(g.boot(&s), GuestOutcome::Installed { ms: 10_011 });
    }

    /// **The estate drives its engine, and the server's state follows it.**
    /// A recording engine, no hypervisor: power-on at `started` with the disk
    /// sized from its record, a guest that powered itself off reads `stopped`,
    /// and a refused power-on never reads `started` at all.
    #[test]
    fn the_estate_boots_its_engine_and_follows_the_machine() {
        use crate::{Clock, Estate, Faults};
        use std::sync::{Arc, Mutex};

        #[derive(Default)]
        struct Rec {
            calls: Mutex<Vec<String>>,
            alive: Mutex<Option<bool>>,
            refuse: bool,
        }
        impl GuestEngine for Rec {
            fn name(&self) -> &'static str {
                "recording"
            }
            fn boot(&self, _: &GuestSpec) -> GuestOutcome {
                GuestOutcome::Installed { ms: 0 }
            }
            fn power_on(&self, m: &Machine) -> Result<(), String> {
                self.calls.lock().unwrap().push(format!("on {} {}G", m.server, m.disks[0].size_gib));
                if self.refuse {
                    return Err("seabios: refused".into());
                }
                *self.alive.lock().unwrap() = Some(true);
                Ok(())
            }
            fn power_off(&self, server: &str, hard: bool) {
                self.calls.lock().unwrap().push(format!("off {server} hard={hard}"));
                if hard {
                    *self.alive.lock().unwrap() = Some(false);
                }
            }
            fn running(&self, _: &str) -> Option<bool> {
                *self.alive.lock().unwrap()
            }
        }

        let rec = Arc::new(Rec::default());
        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 5).with_engine(rec.clone());
        let uuid = e.create_server("a", "a", "2xCPU-4GB", "se-sto1", vec![], "boot", 8).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "started");
        assert_eq!(rec.calls.lock().unwrap()[0], format!("on {uuid} 8G"));

        // The installer powers the box off: nobody called stop, and the API
        // must still say so — `poweroff_notice_ms` later (lane T13's timing
        // rule: the API runs behind the machine, never ahead of it).
        *rec.alive.lock().unwrap() = Some(false);
        e.settle();
        assert_eq!(e.server(&uuid).unwrap().state, "started", "not yet noticed");
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped");

        e.start_server(&uuid).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "started");
        e.stop_server(&uuid, true).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped");
        assert!(rec.calls.lock().unwrap().iter().any(|c| c == &format!("off {uuid} hard=true")));

        let refusing = Arc::new(Rec { refuse: true, ..Rec::default() });
        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 6).with_engine(refusing);
        let uuid = e.create_server("b", "b", "2xCPU-4GB", "se-sto1", vec![], "boot", 8).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped", "a machine that never started is not `started`");
    }

    /// The default skew is the measured one. A mock that booted its guests at
    /// the right time would make gunnar-clock look unnecessary.
    #[test]
    fn a_guest_is_two_hours_ahead() {
        assert_eq!(GuestSpec::new("x", "y").rtc_skew_seconds, 7200);
    }
}