crates/mock-upcloud/src/kvm.rs
//! **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,
/// `disk,cdrom` with a medium in: disk first, a reboot is a reset in place.
pub disk_first: bool,
/// Behaviour 19.
pub rtc_skew_seconds: i64,
/// The template the boot disk was cloned from, when it was — the front's
/// Ubuntu. `None`: a blank disk, filled by an installer (the gunnar boxes).
pub template: Option<String>,
}
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,
disk_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,
template: None,
}
}
}
/// 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,
/// `true` for `disk,cdrom` with a medium in the tray: the disk is tried
/// first, and a guest reboot is a reset in place (the blank-disk install).
pub disk_first: bool,
/// Behaviour 19.
pub rtc_skew_seconds: i64,
/// The utility NIC's MAC, as the API reports it (`ip_addresses`/`networking`).
/// An engine puts this NIC on the estate's ONE utility L2 with this MAC, and
/// [`crate::fabric`] answers its DHCP with [`Machine::utility_offer`].
pub utility_mac: String,
/// What the utility network offers this NIC — the API's own address for it.
pub utility_offer: crate::net::DhcpOffer,
/// **Its first boot's utility DHCP is late** by this many wall ms from the first ask
/// ([`crate::faults::Fault::UtilityDhcpLateFirstBoot`]); 0 = answered at once.
pub dhcp_late_first_boot_ms: u64,
/// The hostname asked for at create — what a template boot calls itself.
pub hostname: String,
/// The keys `login_user` lets in on a template boot.
pub ssh_keys: Vec<String>,
/// **What the metadata service answers this guest as its `user_data`**
/// (`169.254.169.254/metadata/v1/user_data`), when the server was created with
/// `metadata: "yes"` and a `user_data`: a release image's per-order pairing seed.
pub user_data: Option<String>,
/// The PUBLIC address the API reports for this server. With
/// [`crate::estate::Estate::with_public_on_loopback`] it is a loopback
/// address of its own, and an engine serves [`INSTALLER_PUBLIC_PORTS`] on
/// it at the real port numbers — so the installer dials exactly what the
/// API says, as it does on UpCloud. Otherwise TEST-NET, reached by nobody.
pub public_ip: String,
/// Which of [`INSTALLER_PUBLIC_PORTS`] the server's firewall admits NOW
/// ([`admitted_public_ports`]). An engine serves these on
/// [`Machine::public_ip`] and no others; a rule written later reaches it
/// through [`GuestEngine::firewall`].
pub public_admitted: Vec<(&'static str, u16)>,
}
/// **Where the mock's public traffic comes from.** Every packet to a
/// `--public-on-loopback` address is sent by this host — the installer runs on
/// the operator's machine, and the guests cannot route to 127/8 — and the
/// kernel gives it this source (`ip route get 127.80.99.5` → `src 127.0.0.1`).
/// So a rule is judged against it as UpCloud judges one against the operator's
/// address: a rule scoped to some other source does not admit the installer.
pub const OPERATOR_SOURCE: &str = "127.0.0.1";
/// **The installer's ports the firewall lets the operator reach**, judged by
/// [`crate::net::firewall_admits`] exactly as an inbound packet from
/// [`OPERATOR_SOURCE`] would be: off or no rules = all of them, first match
/// wins, a catch-all drop closes every port no rule above it opens.
pub fn admitted_public_ports(
firewall_on: bool,
rules: &[crate::estate::Rule],
) -> Vec<(&'static str, u16)> {
INSTALLER_PUBLIC_PORTS
.iter()
.copied()
.filter(|(proto, port)| {
crate::net::firewall_admits(firewall_on, rules, proto, OPERATOR_SOURCE, 0, *port)
== crate::net::Admit::Accept
})
.collect()
}
/// **The ports the installer (nordisk-pairing 0.3) dials on a VM's PUBLIC
/// address**, at these numbers and no other: the bootstrap listener
/// (`bootstrap::PORT`, tcp/8474) in direct mode, the VM's end of the
/// installer's WireGuard link (`installer_link::LINK_PORT`, udp/51821) in
/// tunnel mode, and the front's relay (udp/51820). The installer knows a VM by
/// the address it dials and uses one port for all of them, so each server
/// needs an address of its own — a loopback port per server would not do.
///
/// And the paid FRONT's plugin insert door, tcp/8475 (terraform: `tcp/8475 admin`,
/// mutual TLS on the installer CA): the operator asks it whether the primary has
/// REGISTERED gunnar's module before vm1 is restarted to take it. Without it on
/// the mock the functional test restarted vm1 blind — MEASURED loki 2026-09-26,
/// a restart a minute after pairing came up `REFUSED [plugin-no-module]` and
/// monetize never started. Served only where the firewall admits it, like the rest.
///
/// And DEV-NARRATE's udp/51822 (Skidbladnir nordisk-narrate, 2026-09-30): the
/// installer knocks there and the box streams its sealed early-boot log back to
/// the knocking address. A DEV estate's terraform admits it; a release estate's
/// never does — so on the mock, as on UpCloud, a release box's port is closed.
///
/// And the FRONT's downstream WireGuard interface, udp/51830 (holger `via::HUB_UDP_PORT`,
/// lane HOL-WGDS/OX-WGDS3): a WireGuard downstream outside the estate dials
/// `<front public>:51830` from its signed invite. Served on the public address like the
/// rest, only where the firewall admits it (the shop's lay writes no rules: admitted).
pub const INSTALLER_PUBLIC_PORTS: &[(&str, u16)] = &[
("tcp", 8474),
("udp", 51820),
("udp", 51821),
("tcp", 8475),
("udp", 51822),
("udp", 51830),
];
/// Is `ip` a loopback address — the public-on-loopback mode's pool?
pub fn is_loopback_public(ip: &str) -> bool {
ip.parse::<std::net::Ipv4Addr>()
.map(|a| a.is_loopback())
.unwrap_or(false)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MachineDisk {
pub storage: String,
pub size_gib: u64,
/// The TEMPLATE this disk was cloned from (its title, e.g. `Ubuntu Server
/// 24.04 LTS (Noble Numbat)`), or `None` for a blank disk. An engine boots
/// a cloned disk as that operating system.
pub template: Option<String>,
}
/// **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. An engine with hot-plug grows the disk
/// under the running machine (QMP `block_resize` on its node); one without
/// grows it 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(())
}
/// **One storage's bytes written over another's** — a storage backup
/// (`from` the volume, `to` the new backup) and its restore (`from` the
/// backup, `to` its origin). The default takes it on paper.
fn copy_disk(&self, _from: &str, _to: &str) -> Result<(), String> {
Ok(())
}
/// **A virtio disk hot-plugged onto a RUNNING server** — `storage/attach`
/// while the machine is up, which is how a data-set growth puts a member
/// back (T21r, 2026-09-22: UpCloud detaches, resizes and re-attaches under
/// a running server, so the mock's guest must see the same). Called after
/// every refusal the estate makes on its own (the address, the 511 fault)
/// and with the device row already written. `Err` is the guest not taking
/// the card: the estate answers `511 HOTPLUG_FAILED` and removes the row.
/// The default takes it on paper, as the mock always did.
fn attach(
&self,
_server: &str,
_storage: &str,
_address: &str,
_size_gib: u64,
) -> Result<(), String> {
Ok(())
}
/// **The unplug** — `storage/detach` on a running server: `device_del`
/// and the WAIT for the guest's acknowledgement. `Err` is no ack (a kernel
/// without PCI hot-plug), and the estate answers `511 HOTPLUG_FAILED` with
/// the device still on. The default acknowledges on paper.
fn detach(&self, _server: &str, _storage: &str, _address: &str) -> 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 metadata service for a running server changed** (`PUT /server` `metadata`):
/// `Some(user_data)` answered from now on, `None` no longer. An engine that serves no
/// metadata ignores it.
fn metadata(&self, _server: &str, _user_data: Option<&str>) {}
/// **The server's firewall changed** (a rule set written, `firewall`
/// switched): `admitted` is what [`admitted_public_ports`] now says. A
/// running machine gets it live — each port no longer admitted stops
/// answering on the public address, each newly admitted one starts — and a
/// stopped one reads [`Machine::public_admitted`] at its next power-on.
/// `Err` is a machine that could not be changed, said by name: the mock
/// then no longer matches the provider for that server.
fn firewall(&self, _server: &str, _admitted: &[(&'static str, u16)]) -> 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 });
}
/// **A soft stop waits for the guest, up to the stop's own timeout in REAL seconds**
/// (UpCloud: the ACPI button, then the guest's own power-off; a hard stop only after
/// `timeout`). Until 2026-10-01 the mock read `stopped` and killed the guest
/// `stop_soft_ms` of VIRTUAL time after the button — at `--speed 20000` well inside a
/// second, so a front that stops cleanly (says so, syncs and freezes its disk, powers
/// off) was hard-stopped mid-way and its next boot replayed the disk's log.
#[test]
fn a_soft_stop_waits_for_the_guest_until_its_timeout_and_only_then_kills() {
use crate::{Clock, Estate, Faults};
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct M {
alive: Mutex<Option<bool>>,
hard_kills: Mutex<u32>,
}
impl GuestEngine for M {
fn name(&self) -> &'static str {
"soft-stop"
}
fn boot(&self, _: &GuestSpec) -> GuestOutcome {
GuestOutcome::Installed { ms: 0 }
}
fn power_on(&self, _: &Machine) -> Result<(), String> {
*self.alive.lock().unwrap() = Some(true);
Ok(())
}
fn power_off(&self, _: &str, hard: bool) {
// A kill is of a RUNNING machine; one already off is not killed again.
if hard && *self.alive.lock().unwrap() == Some(true) {
*self.hard_kills.lock().unwrap() += 1;
*self.alive.lock().unwrap() = Some(false);
}
}
fn running(&self, _: &str) -> Option<bool> {
*self.alive.lock().unwrap()
}
}
let m = Arc::new(M::default());
let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 9).with_engine(m.clone());
let uuid = e
.create_server("f", "f", "2xCPU-4GB", "se-sto1", vec![], "boot", 8)
.unwrap();
e.run_to_quiet();
assert_eq!(e.server(&uuid).unwrap().state, "started");
// The button, a 60 s timeout: the guest is still stopping, so it is not `stopped`.
e.stop_server_within(&uuid, false, Some(60)).unwrap();
e.run_to_quiet();
assert_ne!(
e.server(&uuid).unwrap().state,
"stopped",
"a guest still stopping is not stopped"
);
assert_eq!(
*m.hard_kills.lock().unwrap(),
0,
"nothing killed inside the timeout"
);
// The guest powers itself off: now it is.
*m.alive.lock().unwrap() = Some(false);
e.run_to_quiet();
assert_eq!(e.server(&uuid).unwrap().state, "stopped");
// A timeout of 0: the button, then the kill at once (the provider's hard stop).
e.start_server(&uuid).unwrap();
e.run_to_quiet();
e.stop_server_within(&uuid, false, Some(0)).unwrap();
e.run_to_quiet();
assert_eq!(e.server(&uuid).unwrap().state, "stopped");
assert_eq!(*m.hard_kills.lock().unwrap(), 1);
}
/// **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 public forwards follow the firewall** (the gap LANE-C-NOTES named):
/// a server laid with `firewall: on` and no rules is wide open, so all three
/// installer ports answer; the terraform's rule set then writes a catch-all
/// drop, and only the ports a rule above it admits for the operator stay.
/// A rule scoped to another source does not admit the operator; switching
/// the firewall off opens everything again. Each change reaches the engine,
/// and the next power-on starts with exactly the admitted set.
#[test]
fn the_public_forwards_follow_the_servers_firewall() {
use crate::estate::Rule;
use crate::{Clock, Estate, Faults};
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Fw {
seen: Mutex<Vec<Vec<(&'static str, u16)>>>,
booted: Mutex<Vec<Vec<(&'static str, u16)>>>,
}
impl GuestEngine for Fw {
fn name(&self) -> &'static str {
"firewall-recording"
}
fn boot(&self, _: &GuestSpec) -> GuestOutcome {
GuestOutcome::Installed { ms: 0 }
}
fn power_on(&self, m: &Machine) -> Result<(), String> {
self.booted.lock().unwrap().push(m.public_admitted.clone());
Ok(())
}
fn firewall(&self, _: &str, admitted: &[(&'static str, u16)]) -> Result<(), String> {
self.seen.lock().unwrap().push(admitted.to_vec());
Ok(())
}
}
let accept = |proto: &str, port: &str, src: &str| Rule {
direction: "in".into(),
action: "accept".into(),
family: "IPv4".into(),
protocol: proto.into(),
source_address_start: src.into(),
source_address_end: src.into(),
destination_port_start: port.into(),
destination_port_end: port.into(),
..Rule::default()
};
let drop_all = Rule {
direction: "in".into(),
action: "drop".into(),
..Rule::default()
};
let fw = Arc::new(Fw::default());
let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 11)
.with_public_on_loopback(80, 99)
.with_engine(fw.clone());
let uuid = e
.create_server("main", "main", "2xCPU-4GB", "se-sto1", vec![], "boot", 8)
.unwrap();
e.configure_server(&uuid, vec![], None, true, false, "UTC")
.unwrap();
e.run_to_quiet();
let all: Vec<(&str, u16)> = INSTALLER_PUBLIC_PORTS.to_vec();
assert_eq!(fw.booted.lock().unwrap()[0], all, "on, no rules: wide open");
// main's rules as lane B writes them: 8474 + 51821 from any, 51820 not
// at all (it is the front's relay), a git port, and the catch-all.
e.set_rules(
&uuid,
vec![
accept("tcp", "2222", ""),
accept("tcp", "8474", ""),
accept("udp", "51821", ""),
drop_all.clone(),
],
)
.unwrap();
assert_eq!(
fw.seen.lock().unwrap().last().unwrap(),
&vec![("tcp", 8474), ("udp", 51821)]
);
// A rule for another source does not admit the operator; nothing at all is admitted.
e.set_rules(
&uuid,
vec![accept("udp", "51821", "10.13.8.99"), drop_all.clone()],
)
.unwrap();
assert_eq!(
fw.seen.lock().unwrap().last().unwrap(),
&Vec::<(&str, u16)>::new()
);
// A rule AFTER the drop is dead.
e.set_rules(&uuid, vec![drop_all.clone(), accept("udp", "51821", "")])
.unwrap();
assert!(fw.seen.lock().unwrap().last().unwrap().is_empty());
// The next power-on starts with what the rules admit now.
e.set_rules(&uuid, vec![accept("udp", "51821", ""), drop_all.clone()])
.unwrap();
e.stop_server(&uuid, true).unwrap();
e.run_to_quiet();
e.start_server(&uuid).unwrap();
e.run_to_quiet();
assert_eq!(
fw.booted.lock().unwrap().last().unwrap(),
&vec![("udp", 51821)]
);
// DEV-NARRATE (2026-09-30): udp/51822, the sealed early-boot narration,
// is served ONLY where a rule admits it — a DEV estate's terraform
// writes that rule, a release estate's never does, and then the port is
// closed on the mock exactly as on UpCloud.
e.set_rules(
&uuid,
vec![
accept("udp", "51821", ""),
accept("udp", "51822", ""),
drop_all.clone(),
],
)
.unwrap();
assert_eq!(
fw.seen.lock().unwrap().last().unwrap(),
&vec![("udp", 51821), ("udp", 51822)],
"a dev estate's rule opens the narration port"
);
e.set_rules(&uuid, vec![accept("udp", "51821", ""), drop_all.clone()])
.unwrap();
assert!(
!fw.seen
.lock()
.unwrap()
.last()
.unwrap()
.contains(&("udp", 51822)),
"a release estate's rules leave it closed"
);
// Firewall off: everything, whatever the rules say.
e.configure_server(&uuid, vec![], None, false, false, "UTC")
.unwrap();
assert_eq!(fw.seen.lock().unwrap().last().unwrap(), &all);
}
/// ★ **A server laid with no public interface has no public address** (owner 2026-10-23, the
/// lean estate: main and the twin sit behind the front on the utility network only). The API
/// reports a utility IPv4 alone, nothing is served on a public address, and the engine is
/// handed a machine with no public IP (so the guest is given no public NIC at all).
#[test]
fn a_server_with_no_public_interface_has_no_public_address() {
use crate::{Clock, Estate, Faults};
let mut e =
Estate::new(Clock::virtual_only(), Faults::none(), 12).with_public_on_loopback(80, 99);
let uuid = e
.create_server(
"twin",
"twin",
"STARTER-1xCPU-1GB",
"se-sto1",
vec![],
"boot",
10,
)
.unwrap();
e.configure_server(
&uuid,
vec![crate::estate::Iface {
index: 1,
kind: "utility".into(),
family: "IPv4".into(),
}],
None,
true,
false,
"UTC",
)
.unwrap();
let s = e.server(&uuid).unwrap();
assert!(s.public_ip.is_empty(), "no public address: {}", s.public_ip);
assert!(!s.utility_ip.is_empty());
let v = crate::render::server(s, true, false);
let rows: Vec<&str> = v["ip_addresses"]["ip_address"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["access"].as_str())
.collect();
assert_eq!(
rows,
vec!["utility"],
"the API reports the utility address alone: {v}"
);
// A server that names its public interface keeps it.
let main = e
.create_server(
"main",
"main",
"STARTER-1xCPU-2GB",
"se-sto1",
vec![],
"boot",
10,
)
.unwrap();
e.configure_server(
&main,
crate::estate::Iface::default_pair(),
None,
true,
false,
"UTC",
)
.unwrap();
assert!(!e.server(&main).unwrap().public_ip.is_empty());
}
/// ★ **The front's downstream WireGuard interface is a public port** (lane OX-WGDS3, holger
/// `via::HUB_UDP_PORT`): a WireGuard downstream dials `<front public>:51830`/udp, so the mock
/// serves it as UpCloud does, and with no rules (the shop's lay writes none) it is admitted.
#[test]
fn the_fronts_downstream_wireguard_port_is_served_on_its_public_address() {
assert!(
INSTALLER_PUBLIC_PORTS.contains(&("udp", 51830)),
"{INSTALLER_PUBLIC_PORTS:?}"
);
assert!(
admitted_public_ports(true, &[]).contains(&("udp", 51830)),
"no rules: admitted"
);
assert!(
admitted_public_ports(false, &[]).contains(&("udp", 51830)),
"firewall off: admitted"
);
}
/// 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);
}
}