pub struct Estate {
pub clock: Clock,
pub timings: Timings,
pub faults: Faults,
pub zone: String,
pub upload_base: String,
pub dnat: Vec<Dnat>,
pub maxiops_quota_gib: u64,
pub engine: Arc<dyn GuestEngine>,
/* private fields */
}Fields§
§clock: Clock§timings: Timings§faults: Faults§zone: String§upload_base: StringWhat the mock says its own upload sessions live at. serve fills it in
with the socket it bound, so a caller that FOLLOWS the
direct_upload_url from the import reply reaches the mock instead of
the internet. Empty renders the real provider’s shape
(https://<zone>.img.upcloud.com/uploader/session/<uuid>), which is what
a parser should be tested against.
dnat: Vec<Dnat>Every destination-NAT rule in the estate: the front’s :2222 to the
appliance, and anything else a lay installs. Held here because the
hairpin question (“can the box holding the rule use it”) can only be
answered by something that knows all of them.
maxiops_quota_gib: u64The account’s MaxIOPS quota in GiB (behaviour 44). MEASURED for yvra.
engine: Arc<dyn GuestEngine>What is behind a server. VirtualGuest by default, which runs
nothing and leaves every answer to the state machine below. A real
engine (the draupnir-guest crate’s DraupnirGuest, injected by the
mock-upcloud-kvm binary) boots a QEMU at every started, and the
server’s state then follows that machine: see Estate::reap.
Implementations§
Source§impl Estate
impl Estate
pub fn new(clock: Clock, faults: Faults, seed: u64) -> Estate
pub fn seed(&self) -> u64
Sourcepub fn with_engine(self, engine: Arc<dyn GuestEngine>) -> Estate
pub fn with_engine(self, engine: Arc<dyn GuestEngine>) -> Estate
The same estate with a real machine behind every server.
Sourcepub fn reap(&mut self)
pub fn reap(&mut self)
The server’s state follows its machine. A server that reads
started while its QEMU is gone is a health line that cannot fail —
and the installer powers the box off ~2–3 s after INSTALL-OK, so this is
the ordinary case, not a crash. Only a server the engine has a machine
for is touched (running is None otherwise), so the default engine
changes nothing.
pub fn next_correlation_id(&mut self) -> String
Sourcepub fn tick(&mut self)
pub fn tick(&mut self)
Move every object whose transition is due, then — when the clock is virtual — push time part-way toward the next one.
Part-way, not all the way, on purpose: a client that polls must poll several times, as it does against the real provider. Advancing straight to the next deadline would make every transition complete on the second read and silently excuse a client with no loop at all.
Sourcepub fn run_to_quiet(&mut self)
pub fn run_to_quiet(&mut self)
Jump the virtual clock to the moment nothing is pending any more.
Only two callers, and both are honest about why: a restart is a stop
and a start and the stop must COMPLETE in between, and a test that is
not testing the poll loop says so by calling this. It is a no-op at real
speed, where waiting is the only way through — which is the right
asymmetry: virtual time may skip, real time may not.
Sourcepub fn settle(&mut self)
pub fn settle(&mut self)
Apply every due transition. Idempotent, and the only writer of a state that nobody asked for.
Sourcepub fn resize_filesystem(&mut self, uuid: &str) -> Answer<String>
pub fn resize_filesystem(&mut self, uuid: &str) -> Answer<String>
Behaviour 34 — POST /1.3/storage/{uuid}/resize, the door that
actually left the 44 GB behind.
Behaviour 9 modelled the Resize Backup on a PLAN change, which is a
door this estate never opens. The one it does open is this: cargo xtask grow grows the twin’s volume with PUT /storage/{uuid} (behaviour 22)
and then asks the provider to grow the LAST PARTITION and the xfs inside
it with POST /storage/{uuid}/resize. The provider takes a backup FIRST
and hands it back in the reply as resize_backup — the whole storage
object, uuid and all — and nothing in the estate deletes it afterwards.
MEASURED 2026-09-20: a 44 GB Resize Backup on the live account whose
origin was the twin’s data volume, billed at maxiops since 2026-09-19
21:54:06Z, and upcloud-orphans said “no orphans” over it.
The backup is the resized volume’s size (that is what 44 GB was: the
twin’s volume after its growth), carries the volume’s labels unless
Fault::ResizeBackupUnlabelled is armed, and its origin is the
volume — or a gone uuid under Fault::OrphanResizeBackup. The volume
itself goes maintenance for the resize and comes back online, so a
caller that does not poll gets the same lesson every other write here
teaches.
Refused on a volume attached to a server that is not stopped
(SERVER_STATE_ILLEGAL; the estate stops the twin first and says so in
its journal) and on a volume that is not online.
pub fn server(&self, uuid: &str) -> Option<&Server>
pub fn storage(&self, uuid: &str) -> Option<&Storage>
pub fn was(&self, uuid: &str) -> Option<&'static str>
Sourcepub fn servers_matching(&self, labels: &[(String, String)]) -> Vec<&Server>
pub fn servers_matching(&self, labels: &[(String, String)]) -> Vec<&Server>
GET /1.3/server, filtered by ?label=key=value (all must match).
Behaviour 5: a revoked credential answers this with 200 and ZERO rows. Not 401. A caller that treats “no rows” as “nothing to clean up” deletes nothing and reports success, and a caller that treats it as “nothing exists” creates a second copy of everything.
pub fn storages_matching( &self, labels: &[(String, String)], private_only: bool, ) -> Vec<&Storage>
pub fn create_storage( &mut self, title: &str, size_gib: u64, tier: &str, zone: &str, labels: Vec<Label>, ) -> Answer<String>
Sourcepub fn modify_storage(
&mut self,
uuid: &str,
size_gib: Option<u64>,
title: Option<&str>,
) -> Answer<()>
pub fn modify_storage( &mut self, uuid: &str, size_gib: Option<u64>, title: Option<&str>, ) -> Answer<()>
Behaviour 22. A volume never shrinks. A growth is permanent and billed forever, so the refusal is by NAME and not a silent clamp: a clamp would let a caller believe it had shrunk something.
pub fn delete_storage(&mut self, uuid: &str) -> Answer<()>
pub fn attached_servers(&self, storage: &str) -> Vec<String>
pub fn create_server( &mut self, title: &str, hostname: &str, plan: &str, zone: &str, labels: Vec<Label>, boot_disk_title: &str, boot_disk_gib: u64, ) -> Answer<String>
Sourcepub fn configure_server(
&mut self,
uuid: &str,
ifaces: Vec<Iface>,
boot_order: Option<BootOrder>,
firewall_on: bool,
metadata: bool,
timezone: &str,
) -> Answer<()>
pub fn configure_server( &mut self, uuid: &str, ifaces: Vec<Iface>, boot_order: Option<BootOrder>, firewall_on: bool, metadata: bool, timezone: &str, ) -> Answer<()>
★ The terraform create’s second half. create_server mints the
machine every caller in this crate shares; this says the things only the
terraform door asks for, in ONE place, so the create path is not forked
into two machines that drift.
It is deliberately separate from modify_server: that one is the
PUT /1.3/server/{uuid} a caller makes later and carries the
stop/start behaviours. This is part of the create and makes no
transition at all.
Sourcepub fn rules(&self, uuid: &str) -> Option<&[Rule]>
pub fn rules(&self, uuid: &str) -> Option<&[Rule]>
The rule set of a machine, or the refusal the firewall door owes. The
403s (behaviours 1 and 35) stay in crate::http where they are the
ANSWER rather than the state.
Sourcepub fn set_rules(&mut self, uuid: &str, rules: Vec<Rule>) -> Answer<()>
pub fn set_rules(&mut self, uuid: &str, rules: Vec<Rule>) -> Answer<()>
Write a machine’s whole rule set. UpCloud’s firewall is a SET and not a
list of independently addressable objects — a write replaces it — which
is why upcloud_firewall_rules is one terraform resource per machine
and not one per rule, and why this takes the whole vector.
Sourcepub fn delete_server(&mut self, uuid: &str, with_storages: bool) -> Answer<()>
pub fn delete_server(&mut self, uuid: &str, with_storages: bool) -> Answer<()>
Behaviour 11. A server must be STOPPED before it can be deleted with its storages. A started one is refused, not queued.
Behaviour 7. The delete itself sits in maintenance for 60 s plus
65 s per attached member volume — an appliance carrying four of them is
over five minutes, which is what was measured and what every caller’s
timeout was too short for.
Sourcepub fn start_server(&mut self, uuid: &str) -> Answer<()>
pub fn start_server(&mut self, uuid: &str) -> Answer<()>
Behaviour 3. Out of stock, at poweron, with a 412. It was measured
on fr-par-1 from 2026-09-09 and it did not clear for days, so the fault
is sticky: a retry loop must not be able to outwait it.
pub fn stop_server(&mut self, uuid: &str, hard: bool) -> Answer<()>
Sourcepub fn modify_server(
&mut self,
uuid: &str,
plan: Option<&str>,
boot_order: Option<BootOrder>,
labels: Option<Vec<Label>>,
remote_access: Option<bool>,
remote_access_password: Option<&str>,
) -> Answer<()>
pub fn modify_server( &mut self, uuid: &str, plan: Option<&str>, boot_order: Option<BootOrder>, labels: Option<Vec<Label>>, remote_access: Option<bool>, remote_access_password: Option<&str>, ) -> Answer<()>
PUT /1.3/server/{uuid} — the plan change, the boot order, the labels
and the remote_access_enabled toggle, which is the whole of what this
estate uses it for.
Sourcepub fn rename_server(
&mut self,
uuid: &str,
hostname: Option<&str>,
title: Option<&str>,
)
pub fn rename_server( &mut self, uuid: &str, hostname: Option<&str>, title: Option<&str>, )
A server’s hostname and title, changed in place.
pub fn attach( &mut self, server: &str, storage: &str, kind: &str, ) -> Answer<String>
Sourcepub fn attach_at(
&mut self,
server: &str,
storage: &str,
kind: &str,
want: Option<&str>,
) -> Answer<String>
pub fn attach_at( &mut self, server: &str, storage: &str, kind: &str, want: Option<&str>, ) -> Answer<String>
An attach, at the address the caller asked for (behaviour 58).
virtio:3 is honoured; virtio (or nothing) lets UpCloud pick the
first FREE slot — REPORTED (monetize-impl grow.rs: address:"virtio"
lets UpCloud pick). The mock used to ignore the request and take
virtio:<count>, which collides after a middle detach and never puts a
disk where a caller asked. Where it lands decides the guest’s name for
it (Estate::guest_disk_names), and that decides the installer’s
disk election.
Sourcepub fn guest_disk_names(&self, server: &str) -> Vec<(String, String)>
pub fn guest_disk_names(&self, server: &str) -> Vec<(String, String)>
The names the GUEST gives the disks (behaviour 58). virtio-blk
devices are named in PCI slot order and the names are CONTIGUOUS:
virtio:0, virtio:2, virtio:5 are vda, vdb, vdc. MEASURED as a rule
the ladder depends on (gunnar machine.rs, holger HOLGER-PLAN: the ISO’s
virtio copy is attached AFTER the data volume “so that it becomes vdc,
not vdb”), and it is what korp-installer’s disk election reads. The
IDE cdrom is not a Linux block device here: the kernel has no ATA.
Sourcepub fn attach_at_create(
&mut self,
server: &str,
storage: &str,
kind: &str,
want: Option<&str>,
) -> Answer<String>
pub fn attach_at_create( &mut self, server: &str, storage: &str, kind: &str, want: Option<&str>, ) -> Answer<String>
A device named in the CREATE body (storage_devices entries with
"action": "attach"). The server is in maintenance and has never run,
so the cdrom’s hot-plug refusal in Estate::attach does not apply:
this is how the provider takes an installer medium at create. The storage
must exist and be online, exactly as for a later attach.
Sourcepub fn detach(&mut self, server: &str, address: &str) -> Answer<()>
pub fn detach(&mut self, server: &str, address: &str) -> Answer<()>
Behaviour 16, the half that refuses. A detach names an ADDRESS, and
on a STARTED server an ide address is refused IDE_HOTPLUG_UNSUPPORTED,
and a virtio one is the guest’s to allow (behaviour 60: a kernel without
PCI hot-plug answers 511 HOTPLUG_FAILED). The only thing that takes a medium off a running box
is Estate::eject.
Sourcepub fn eject(&mut self, server: &str) -> Answer<()>
pub fn eject(&mut self, server: &str) -> Answer<()>
Behaviour 16, the half that works. cdrom/eject takes the MEDIUM out
and leaves the device. MEASURED 200 on a STARTED server on 2026-09-14,
and it ended an install loop: the next boot found no medium and fell
through to the disk. It is the only never-loop primitive there is.
Sourcepub fn load_cdrom(&mut self, server: &str, storage: &str) -> Answer<()>
pub fn load_cdrom(&mut self, server: &str, storage: &str) -> Answer<()>
cdrom/load — the other half of eject. Puts a storage into the
existing cdrom device of an existing server, so a re-image with a
DIFFERENT medium needs no detach and no re-create. With the CD first in
the boot order, the next hypervisor start boots it (L50).
NOT MEASURED, and not in the ledger: the codes are UpCloud 1.3’s
documented ones for this endpoint. What is modelled: it works on a
started server like eject does (L52); a tray that already holds a
medium is refused (CDROM_DEVICE_IN_USE, eject first); a server with no
cdrom device is refused (CDROM_DEVICE_NOT_FOUND); the storage must
exist and be online. The provider also requires the storage to be of
type cdrom; the mock does not, because its uploaded media are normal
storages that the ladder attaches as type: cdrom, which is MEASURED to
work (L49).
Sourcepub fn reach(&self, from: &str, dest: &str, port: u16) -> Answer<Reach>
pub fn reach(&self, from: &str, dest: &str, port: u16) -> Answer<Reach>
Can from reach dest:port, from where it is standing?
There is no such thing here as “is that address up” — only “is it up from here”. Both of the asymmetries this models (a guest with no route off its prefix, and a box that cannot use its own DNAT) make the answer depend on the asker, and a reachability call that did not name one would be answering a question nobody has.
Sourcepub fn inbound(
&self,
from_ip: &str,
to: &str,
proto: &str,
port: u16,
) -> Answer<Reach>
pub fn inbound( &self, from_ip: &str, to: &str, proto: &str, port: u16, ) -> Answer<Reach>
The three paths a host key must answer identically, which is what tells a re-imaged machine from a hijacked name.
A re-image mints a new key, so REMOTE HOST IDENTIFICATION HAS CHANGED
is expected and must not be trusted blindly. The check that makes it
safe is that the SAME key answers on the name, on the front’s public
address, and on the appliance’s OWN public address bypassing the DNAT.
Fault::HijackedName makes the name path answer a different key,
which is the case the whole check exists for — and a verifier that only
looked at one path would accept it.
Behaviour 62: can from_ip reach to:port INBOUND, through the
provider’s firewall? Dropped is silence (the firewall), Refused
is an RST (admitted, nothing listening — a server that is not
started), Ok is a listener.
Sourcepub fn udp_reply_arrives(
&self,
server: &str,
from_ip: &str,
from_port: u16,
) -> Answer<bool>
pub fn udp_reply_arrives( &self, server: &str, from_ip: &str, from_port: u16, ) -> Answer<bool>
Behaviour 62, the UDP half: does the reply to this server’s own
outbound query come back? The reply is an inbound packet FROM
from_ip:from_port (53 for DNS, 123 for NTP) to an ephemeral port, and
the firewall is stateless for UDP — so a rule set ending in a catch-all
drop eats it, and a server with its firewall off gets it. This is the
MECHANISM behind Fault::UdpInboundDropped, which stays the
estate-wide default-ON approximation for callers that ask no server.
pub fn host_key_via(&self, uuid: &str, path: HostKeyPath) -> Answer<String>
Sourcepub fn relay(&mut self)
pub fn relay(&mut self)
A lay of the estate: every address is reshuffled. Nothing else changes —
this is what a xtask estate rebuild does to the ADDRESSES, and the
point is that the next lay’s appliance is not on the last lay’s IP.
pub fn lays(&self) -> u64
Sourcepub fn start_import(&mut self, uuid: &str, source: &str) -> Answer<Import>
pub fn start_import(&mut self, uuid: &str, source: &str) -> Answer<Import>
POST /1.3/storage/{uuid}/import — open a direct-upload session.
The session is prepared and nothing has moved yet. The URL it hands
back is the mock’s own, so a caller that follows the reply (rather than
building the URL itself) reaches the mock without being told to.
Sourcepub fn upload(&mut self, uuid: &str, bytes: &[u8]) -> Answer<Import>
pub fn upload(&mut self, uuid: &str, bytes: &[u8]) -> Answer<Import>
The PUT of the bytes to the session URL.
read_bytes and written_bytes are the REAL count and the digests are
the REAL digests — the ladder compares sha256sum with the local file’s,
so a made-up value here would make the verification pass without ever
having run. Five seconds for a 43 MB ISO, and then the storage enters
syncing for a hundred and something more.
Sourcepub fn clone_storage(&mut self, uuid: &str, title: &str) -> Answer<String>
pub fn clone_storage(&mut self, uuid: &str, title: &str) -> Answer<String>
POST /1.3/storage/{uuid}/clone — the candidate fix.
The ladder uploads the same 43 MB medium twice per re-image, once as a
CD-ROM and once as a virtio disk, because korp-installer probes virtio
and nothing else. The second one could be a clone of the first: the same
bytes, the same sha256, no second upload.
Behaviour 53, MEASURED 2026-09-20 (gunnar clone_probe.rs): the call
is quick, then maintenance → online in 47 s with NO syncing. This
doc used to say the answer was not known; it was, one repository over.
And the same probe says why it is still not the fix: a clone cannot
start until its source is online, so import→clone is serial (161 s)
where two parallel imports are ~115 s. Fault::CloneSyncsLikeImport
keeps the old pessimistic guess, by name.
Sourcepub fn all_servers(&self) -> impl Iterator<Item = &Server>
pub fn all_servers(&self) -> impl Iterator<Item = &Server>
Everything, for the mock’s own /mock/estate view and for the tests.