Skip to main content

mock_upcloud/
kvm.rs

1//! **The KVM half: the seam, and what a real guest has to do to fill it.**
2//!
3//! Behaviours 8 and 13–21 are not API behaviours. They are things a GUEST does,
4//! and no amount of JSON reproduces them: a PID-1 panic that is invisible
5//! except on the framebuffer is only invisible if there is a framebuffer, and an
6//! installer that loops only loops if something really boots twice. So the mock
7//! delegates the guest to a [`GuestEngine`], and a real one is a qemu.
8//!
9//! # Why the real engine is not in this crate's dependencies
10//!
11//! draupnir is the law's door to a hypervisor and this crate starts no qemu of
12//! its own. But draupnir's live KVM backend (`backend-tunnr`) depends on
13//! `tunnr-vm` by PATH, and tunnr is not published. An optional dependency is
14//! still resolved into the lockfile, so naming draupnir here would make a fresh
15//! clone of the open `monetize` workspace require a `tunnr` checkout beside it —
16//! the exact failure this workspace already paid for with Skidbladnir and wrote
17//! down in its root manifest.
18//!
19//! So the seam is here and the binding is not. The binding is the detached
20//! `kvm-guest` crate beside this one (`DraupnirGuest`, rendered by draupnir's
21//! own `draupnir::qemu`, not tunnr's argv — see the measurement below for why
22//! tunnr's could not express this machine), and its `mock-upcloud-kvm` binary
23//! serves this API with a real QEMU behind every server. `cargo test -p
24//! mock-upcloud` needs no hypervisor: the default engine runs nothing.
25//!
26//! # What the real engine must do, and what each clause is for
27//!
28//! | clause | qemu | behaviour |
29//! |---|---|---|
30//! | no serial console | `-serial none` | 13 — a PID-1 panic reaches nobody |
31//! | a framebuffer, and only that | `-vga std -display vnc=…` | 13 — the panic IS visible, on the pixels |
32//! | SeaBIOS, never OVMF | no `-bios OVMF*`, no `pflash` | 15 — the BIOS path is the one that breaks |
33//! | RTC two hours ahead | `-rtc base=<now+2h>` | 19 — the skew gunnar-clock exists for |
34//! | no inbound UDP | a user-mode net with no `hostfwd` for udp | 18 — DNS and NTP are useless in there |
35//! | the CD first while it is inserted | `-boot order=dc` | 17 — the loop |
36//!
37//! A [`GuestEngine`] that cannot honour a clause must REFUSE it by name rather
38//! than quietly boot without it. A guest that silently got a serial console
39//! would make behaviour 13 untestable while reporting that it had been tested,
40//! which is the always-yes defect this estate has now found four times.
41//!
42//! # MEASURED 2026-09-20 on oden: draupnir/tunnr cannot express this machine
43//!
44//! `tunnr::boot_primitive` builds its argv unconditionally, and three of its
45//! lines contradict three of the clauses above:
46//!
47//! * `args.push("-serial"); args.push("mon:stdio")` — **always**, with the
48//!   comment "Serial → stdio so we capture the console and detect the BOOT-OK
49//!   marker". Behaviour 13 says there is no serial console, and the marker
50//!   protocol is built on there being one.
51//! * `// UEFI via OVMF pflash, exactly as the design mandates.` — the firmware
52//!   boot path is OVMF, unconditionally. Behaviour 15 says SeaBIOS, and UpCloud
53//!   offers no knob.
54//! * `if spec.headless { args.push("-nographic") }` — which removes the
55//!   framebuffer entirely. Behaviour 13 says the framebuffer is the ONLY place
56//!   a PID-1 panic appears.
57//!
58//! So the estate's KVM proofs have been run on a machine that is not the
59//! machine UpCloud hands out — a UEFI box with a serial console and no screen,
60//! against a BIOS box with a screen and no serial. That is a large part of why
61//! an image that "booted fine locally" could panic invisibly up there.
62//!
63//! The faithful argv, run against the real `gunnar-installer-open-fullk.iso`
64//! (59 496 448 bytes) on oden on 2026-09-20:
65//!
66//! ```text
67//! qemu-system-x86_64 -enable-kvm -m 2048 -smp 2 -no-reboot \
68//!   -bios /usr/share/seabios/bios-256k.bin \
69//!   -drive file=disk.qcow2,if=virtio,format=qcow2 -cdrom <iso> -boot order=dc \
70//!   -serial none -vga std -display none -vnc 127.0.0.1:91 \
71//!   -rtc base=<host time + 2h> \
72//!   -netdev user,id=n0,dns=10.0.2.99 -device virtio-net-pci,netdev=n0
73//! ```
74//!
75//! What it measured: **zero bytes** on stdout across every run, and the
76//! installer's whole narration present on the framebuffer — `INSTALLER:
77//! payload app.znippy materialized off the ISO volume (8580685 bytes)`,
78//! `policy: require_signed=NO`, and `FAILED hwdetect: no usable target disk`
79//! on the run with no disk attached. Two screendumps twenty-five seconds apart
80//! hashed differently, so the guest really progressed, and the qcow2 grew from
81//! 91 951 104 to 93 786 112 bytes, so the install really wrote. There is no
82//! log to quote for any of it. That is the point.
83//!
84//! `dns=10.0.2.99` is how behaviour 18 is modelled: slirp serves DNS on its own
85//! `.3` and nothing else, so the guest's queries leave and no reply ever
86//! arrives — which is what `systemd-timesyncd` experiences up there, and why
87//! gunnar-clock syncs from the front over a signed nonce instead.
88//!
89//! **What tunnr would need** for draupnir to host this: a `firmware`
90//! (`Bios`/`Uefi`) choice on its `BootSpec`, a `serial` (`Stdio`/`None`)
91//! choice, and a headless mode that keeps `-vga std` and drops only the
92//! display. Three fields. Until they exist the seam below is the honest shape,
93//! and a `DraupnirGuest` would have to REFUSE `no-serial-console` and
94//! `seabios` by name rather than boot without them.
95
96use std::fmt;
97
98/// What the mock asks a hypervisor for.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct GuestSpec {
101    pub name: String,
102    pub mem_mb: u32,
103    pub cores: u32,
104    /// The disk image the server boots.
105    pub disk: String,
106    /// The medium in the cdrom device, if any. `None` is an ejected drive.
107    pub medium: Option<String>,
108    /// `true` while the CD is first in the boot order — behaviour 17's whole
109    /// mechanism.
110    pub cdrom_first: bool,
111    /// Behaviour 19.
112    pub rtc_skew_seconds: i64,
113}
114
115impl GuestSpec {
116    pub fn new(name: impl Into<String>, disk: impl Into<String>) -> GuestSpec {
117        GuestSpec {
118            name: name.into(),
119            mem_mb: 2048,
120            cores: 2,
121            disk: disk.into(),
122            medium: None,
123            cdrom_first: false,
124            // +2 h. Measured on every yvra VM, which is why gunnar-clock syncs
125            // from the FRONT over a signed nonce and never from the internet.
126            rtc_skew_seconds: 7200,
127        }
128    }
129}
130
131/// What a guest did. Deliberately NOT a log: behaviour 20 is that nothing
132/// narrates the install window, so an engine that returned the guest's output
133/// would be lying about the one thing this half exists to prove.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub enum GuestOutcome {
136    /// The installer ran and the box came up on its disk. `ms` is the measured
137    /// install time — 10 011 ms under KVM for korp-installer.
138    Installed { ms: u64 },
139    /// The installer ran again because the CD was still first. Behaviour 17.
140    Looped { rounds: u32 },
141    /// PID 1 died. Visible only as pixels, so `frame_sha256` is the ONLY
142    /// evidence — there is no serial log to quote.
143    Panicked { frame_sha256: String },
144    /// The engine refused a clause of the spec by name rather than booting
145    /// without it.
146    Refused { clause: &'static str, why: String },
147}
148
149impl fmt::Display for GuestOutcome {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            GuestOutcome::Installed { ms } => write!(f, "installed in {ms} ms"),
153            GuestOutcome::Looped { rounds } => write!(f, "installer looped {rounds} times"),
154            GuestOutcome::Panicked { frame_sha256 } => {
155                write!(f, "PID 1 panicked; the only evidence is the framebuffer ({frame_sha256})")
156            }
157            GuestOutcome::Refused { clause, why } => write!(f, "refused {clause}: {why}"),
158        }
159    }
160}
161
162/// **One server's machine, as the estate describes it at power-on.**
163///
164/// Everything is named by the ESTATE's ids — server and storage uuids — and
165/// never by a path: where a disk lives is the engine's business, so this crate
166/// stays free of any hypervisor and of any filesystem layout.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct Machine {
169    /// The server uuid.
170    pub server: String,
171    pub mem_mb: u32,
172    pub cores: u32,
173    /// The attached disks, boot disk first, each at the size its STORAGE record
174    /// says now (not the size it was attached at: a resize changes the record).
175    pub disks: Vec<MachineDisk>,
176    /// The storage uuid in the cdrom tray, if any. `None` is an ejected drive.
177    pub medium: Option<String>,
178    /// `true` while the CD is first in the boot order (behaviour 17).
179    pub cdrom_first: bool,
180    /// Behaviour 19.
181    pub rtc_skew_seconds: i64,
182}
183
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct MachineDisk {
186    pub storage: String,
187    pub size_gib: u64,
188}
189
190/// **The seam.** `boot` is the one-shot question a storm asks; the lifecycle
191/// methods below are what the estate calls when an API request changes a
192/// server, so that behind the JSON there is a machine.
193///
194/// Every lifecycle method has a default that does NOTHING, and
195/// [`running`](GuestEngine::running) defaults to `None` ("this engine has no
196/// machine to ask"), which is how the estate knows to keep its own state
197/// machine. So the default engine is exactly the mock as it was, and
198/// `cargo test -p mock-upcloud` needs no hypervisor.
199///
200/// All calls are made with the estate's lock held and must return promptly: a
201/// soft stop SENDS the ACPI event and returns, it does not wait for the guest.
202pub trait GuestEngine: Send + Sync {
203    fn name(&self) -> &'static str;
204    fn boot(&self, spec: &GuestSpec) -> GuestOutcome;
205
206    /// Power the machine on: create any disk that does not exist yet, sized
207    /// from its storage record, grow any that is smaller, and boot. An `Err` is
208    /// a refusal by name, and the estate reports the server `stopped` — a
209    /// server whose machine never started must not read `started`.
210    fn power_on(&self, _machine: &Machine) -> Result<(), String> {
211        Ok(())
212    }
213    /// `hard`: kill it now. Soft: press the ACPI power button and return.
214    fn power_off(&self, _server: &str, _hard: bool) {}
215    /// `Some(true)` a machine is running for this server, `Some(false)` one was
216    /// started and is gone (it powered itself off, or died), `None` this engine
217    /// has never run one — the estate's own state machine then stands.
218    fn running(&self, _server: &str) -> Option<bool> {
219        None
220    }
221    /// Take the medium out of a running (or stopped) machine's tray.
222    fn eject(&self, _server: &str) {}
223    /// Where the server's console REALLY listens — a loopback host and port
224    /// for a real guest's VNC. `None` (the default) leaves the estate's
225    /// RFC 2606 `<zone>.vnc.mock.invalid`.
226    fn console(&self, _server: &str) -> Option<(String, u16)> {
227        None
228    }
229    /// Put the medium of `storage` into the server's tray (`cdrom/load`). A
230    /// running machine gets it live; a stopped one reads it at the next
231    /// power-on either way, because [`Machine::medium`] is built from the
232    /// device row.
233    fn load(&self, _server: &str, _storage: &str) {}
234    /// A storage's size record grew. The engine grows the disk only while no
235    /// running machine has it open, and otherwise at the next power-on.
236    fn resize_disk(&self, _storage: &str, _size_gib: u64) -> Result<(), String> {
237        Ok(())
238    }
239    /// The bytes an upload session received, kept as that storage's medium.
240    fn store_medium(&self, _storage: &str, _bytes: &[u8]) -> Result<(), String> {
241        Ok(())
242    }
243    /// The server is being deleted: kill its machine and drop its records.
244    fn forget_server(&self, _server: &str) {}
245    /// The storage is gone from the account: delete its disk and medium.
246    fn forget_storage(&self, _storage: &str) {}
247    /// The whole estate is being replaced (`/mock/seed`): every machine and
248    /// every disk goes with it, because nothing can refer to them any more.
249    fn forget_all(&self) {}
250    /// What the machine left behind, for `/mock/guest/{uuid}`: argv, frame
251    /// hashes, stdout bytes, disk sizes. `None` from an engine with no machines.
252    fn evidence(&self, _server: &str) -> Option<serde_json::Value> {
253        None
254    }
255}
256
257/// **The guest as a state machine, for the runs that are not about the guest.**
258///
259/// It does not boot anything, and it says so in its name. Its whole value is
260/// that the 100 000-run storm is about the API and the money, and paying ten
261/// seconds of real qemu per iteration would make the storm a three-week job. The
262/// outcomes it produces are the ones the real engine produces, in the same
263/// proportions, drawn from the run's seed.
264pub struct VirtualGuest {
265    pub install_ms: u64,
266}
267
268impl Default for VirtualGuest {
269    fn default() -> Self {
270        VirtualGuest { install_ms: 10_011 }
271    }
272}
273
274impl GuestEngine for VirtualGuest {
275    fn name(&self) -> &'static str {
276        "virtual"
277    }
278
279    fn boot(&self, spec: &GuestSpec) -> GuestOutcome {
280        match (spec.cdrom_first, &spec.medium) {
281            // The CD is first AND a medium is in the drive: the installer runs,
282            // finishes, and the next boot finds the CD first again.
283            (true, Some(_)) => GuestOutcome::Looped { rounds: 2 },
284            (_, _) => GuestOutcome::Installed { ms: self.install_ms },
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn the_cd_first_with_a_medium_in_it_is_the_loop() {
295        let g = VirtualGuest::default();
296        let mut s = GuestSpec::new("appliance", "/var/lib/mock/appliance.qcow2");
297        s.medium = Some("/var/lib/mock/gunnar.iso".into());
298        s.cdrom_first = true;
299        assert!(matches!(g.boot(&s), GuestOutcome::Looped { .. }));
300        // The eject is the cure: no medium, no loop, whatever the boot order.
301        s.medium = None;
302        assert!(matches!(g.boot(&s), GuestOutcome::Installed { .. }));
303    }
304
305    #[test]
306    fn the_measured_install_is_ten_thousand_and_eleven_milliseconds() {
307        let g = VirtualGuest::default();
308        let s = GuestSpec::new("front", "/var/lib/mock/front.qcow2");
309        assert_eq!(g.boot(&s), GuestOutcome::Installed { ms: 10_011 });
310    }
311
312    /// **The estate drives its engine, and the server's state follows it.**
313    /// A recording engine, no hypervisor: power-on at `started` with the disk
314    /// sized from its record, a guest that powered itself off reads `stopped`,
315    /// and a refused power-on never reads `started` at all.
316    #[test]
317    fn the_estate_boots_its_engine_and_follows_the_machine() {
318        use crate::{Clock, Estate, Faults};
319        use std::sync::{Arc, Mutex};
320
321        #[derive(Default)]
322        struct Rec {
323            calls: Mutex<Vec<String>>,
324            alive: Mutex<Option<bool>>,
325            refuse: bool,
326        }
327        impl GuestEngine for Rec {
328            fn name(&self) -> &'static str {
329                "recording"
330            }
331            fn boot(&self, _: &GuestSpec) -> GuestOutcome {
332                GuestOutcome::Installed { ms: 0 }
333            }
334            fn power_on(&self, m: &Machine) -> Result<(), String> {
335                self.calls.lock().unwrap().push(format!("on {} {}G", m.server, m.disks[0].size_gib));
336                if self.refuse {
337                    return Err("seabios: refused".into());
338                }
339                *self.alive.lock().unwrap() = Some(true);
340                Ok(())
341            }
342            fn power_off(&self, server: &str, hard: bool) {
343                self.calls.lock().unwrap().push(format!("off {server} hard={hard}"));
344                if hard {
345                    *self.alive.lock().unwrap() = Some(false);
346                }
347            }
348            fn running(&self, _: &str) -> Option<bool> {
349                *self.alive.lock().unwrap()
350            }
351        }
352
353        let rec = Arc::new(Rec::default());
354        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 5).with_engine(rec.clone());
355        let uuid = e.create_server("a", "a", "2xCPU-4GB", "se-sto1", vec![], "boot", 8).unwrap();
356        e.run_to_quiet();
357        assert_eq!(e.server(&uuid).unwrap().state, "started");
358        assert_eq!(rec.calls.lock().unwrap()[0], format!("on {uuid} 8G"));
359
360        // The installer powers the box off: nobody called stop, and the API
361        // must still say so — `poweroff_notice_ms` later (lane T13's timing
362        // rule: the API runs behind the machine, never ahead of it).
363        *rec.alive.lock().unwrap() = Some(false);
364        e.settle();
365        assert_eq!(e.server(&uuid).unwrap().state, "started", "not yet noticed");
366        e.run_to_quiet();
367        assert_eq!(e.server(&uuid).unwrap().state, "stopped");
368
369        e.start_server(&uuid).unwrap();
370        e.run_to_quiet();
371        assert_eq!(e.server(&uuid).unwrap().state, "started");
372        e.stop_server(&uuid, true).unwrap();
373        e.run_to_quiet();
374        assert_eq!(e.server(&uuid).unwrap().state, "stopped");
375        assert!(rec.calls.lock().unwrap().iter().any(|c| c == &format!("off {uuid} hard=true")));
376
377        let refusing = Arc::new(Rec { refuse: true, ..Rec::default() });
378        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 6).with_engine(refusing);
379        let uuid = e.create_server("b", "b", "2xCPU-4GB", "se-sto1", vec![], "boot", 8).unwrap();
380        e.run_to_quiet();
381        assert_eq!(e.server(&uuid).unwrap().state, "stopped", "a machine that never started is not `started`");
382    }
383
384    /// The default skew is the measured one. A mock that booted its guests at
385    /// the right time would make gunnar-clock look unnecessary.
386    #[test]
387    fn a_guest_is_two_hours_ahead() {
388        assert_eq!(GuestSpec::new("x", "y").rtc_skew_seconds, 7200);
389    }
390}