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}