Skip to main content

mock_upcloud/
estate.rs

1//! **The state machine: what the account holds, and how it moves.**
2//!
3//! Everything the mock answers comes from here. There are no canned replies —
4//! a canned reply cannot be *early*, and "early" is the whole difficulty with
5//! this provider: a storage is `maintenance` before it is `online`, a server is
6//! `maintenance` for a hundred seconds after it is created, and a delete of an
7//! appliance with four member volumes is `maintenance` for five minutes. A test
8//! fixture that answers `online` on the first read has tested nothing.
9//!
10//! # Transitions
11//!
12//! Every object may carry one scheduled [`Transition`]: a state to enter at a
13//! virtual millisecond, and optionally a thing to do on arrival (mint the
14//! `Resize Backup`, free the addresses, boot the guest). [`Estate::settle`] is
15//! the only place a state changes on its own, and it is called at the top of
16//! every request — so the state a caller sees is always the state as of the
17//! moment it asked, and never a state nothing scheduled.
18
19use crate::clock::{Clock, Timings};
20use crate::faults::{Fault, Faults};
21use crate::kvm::{GuestEngine, Machine, MachineDisk, VirtualGuest};
22use crate::rng::SplitMix64;
23use std::collections::BTreeMap;
24use std::sync::Arc;
25
26// ── the objects ──────────────────────────────────────────────────────────────
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Label {
30    pub key: String,
31    pub value: String,
32}
33
34/// UpCloud's storage kinds. `Normal` is a disk, `Backup` is what a resize (and a
35/// backup rule) leaves behind, `Cdrom` is an installer medium, `Template` is a
36/// public image.
37#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum StorageKind {
39    Normal,
40    Backup,
41    Cdrom,
42    Template,
43}
44
45impl StorageKind {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            StorageKind::Normal => "normal",
49            StorageKind::Backup => "backup",
50            StorageKind::Cdrom => "cdrom",
51            StorageKind::Template => "template",
52        }
53    }
54}
55
56#[derive(Clone, Debug)]
57pub struct Storage {
58    pub uuid: String,
59    pub title: String,
60    pub size_gib: u64,
61    pub tier: String,
62    pub zone: String,
63    pub state: String,
64    pub kind: StorageKind,
65    pub labels: Vec<Label>,
66    /// The volume this was made from. On a `Resize Backup` it points at the
67    /// boot volume that was resized — which, once that server is deleted, names
68    /// a uuid that no longer resolves (behaviour 9, and
69    /// [`Fault::OrphanResizeBackup`] makes it so immediately).
70    pub origin: Option<String>,
71    /// Virtual ms, rendered as `created`. **It IS sent** — measured against the
72    /// live account 2026-09-20 on both `GET /1.3/storage/private` and
73    /// `GET /1.3/storage/{uuid}`. This crate used to withhold it and claim the
74    /// provider did; [`Fault::WithholdCreatedField`] now takes it away only when
75    /// armed by name, as a hypothesis.
76    pub created_ms: u64,
77    /// The direct-upload session, if one was ever opened on this volume.
78    ///
79    /// **It has its OWN clock, and that is the point.** MEASURED on the live
80    /// estate: a 43 485 184-byte ISO was `created 09:36:16Z` and `completed
81    /// 09:36:21Z` — five seconds — and then the STORAGE sat in `syncing` for
82    /// roughly 100 to 130 seconds more before it turned `online`. The poller
83    /// read `syncing` at 65 s and at 97 s while this object already said
84    /// `completed`. A mock that turned the storage `online` when the upload
85    /// finished would hide the entire cost, which is all of the cost.
86    pub import: Option<Import>,
87    pub transition: Option<Transition>,
88}
89
90/// **The direct-upload session.** `POST /1.3/storage/{uuid}/import` opens it,
91/// a `PUT` to its `direct_upload_url` fills it, and its fields are what the
92/// ladder verifies against — `sha256sum` in particular, which is compared with
93/// the local file's. The digests here are the REAL digests of the REAL bytes
94/// that were really PUT; see [`crate::digest`] for why they are not faked.
95#[derive(Clone, Debug)]
96pub struct Import {
97    pub source: String,
98    /// `prepared` | `uploading` | `completed` | `failed`.
99    pub state: String,
100    pub created_ms: u64,
101    pub completed_ms: Option<u64>,
102    pub client_content_length: u64,
103    pub read_bytes: u64,
104    pub written_bytes: u64,
105    pub md5sum: Option<String>,
106    pub sha256sum: Option<String>,
107    pub error_code: Option<String>,
108    pub error_message: Option<String>,
109    pub direct_upload_url: String,
110}
111
112/// How a device rides on a server. UpCloud's `address` is `virtio:N` or `ide:B:D`,
113/// and a detach names the ADDRESS, never the storage uuid.
114#[derive(Clone, Debug)]
115pub struct Device {
116    pub address: String,
117    pub storage: String,
118    pub storage_title: String,
119    pub storage_size: u64,
120    pub kind: &'static str,
121    pub boot_disk: bool,
122}
123
124#[derive(Clone, Copy, PartialEq, Eq, Debug)]
125pub enum BootOrder {
126    Cdrom,
127    Disk,
128    /// **`cdrom,disk` — the appliance's boot order, and it is not a synonym for
129    /// `cdrom`.** holger's appliance and twin declare it so a re-image is
130    /// "attach the medium and reboot" and an empty drive falls straight through
131    /// to the disk. For behaviour 17 (an install killed mid-flight loops
132    /// forever) it behaves exactly as `cdrom` does, because the CD is still
133    /// FIRST — which is the whole of what makes the loop.
134    CdromDisk,
135}
136
137impl BootOrder {
138    pub fn as_str(self) -> &'static str {
139        match self {
140            BootOrder::Cdrom => "cdrom",
141            BootOrder::Disk => "disk",
142            BootOrder::CdromDisk => "cdrom,disk",
143        }
144    }
145
146    pub fn parse(s: &str) -> Option<BootOrder> {
147        match s {
148            "cdrom" => Some(BootOrder::Cdrom),
149            "disk" => Some(BootOrder::Disk),
150            "cdrom,disk" => Some(BootOrder::CdromDisk),
151            _ => None,
152        }
153    }
154
155    /// Whether an attached medium is tried BEFORE the disk. The one question
156    /// the boot path actually asks, so `cdrom,disk` cannot be forgotten at the
157    /// place where forgetting it silently ends the installer loop.
158    pub fn cdrom_first(self) -> bool {
159        matches!(self, BootOrder::Cdrom | BootOrder::CdromDisk)
160    }
161}
162
163/// What the guest is doing. The mock keeps this even without the `kvm` feature,
164/// because the API's answers depend on it (an installer that loops never leaves
165/// `Installing`) and because the KVM half must have somewhere to report to.
166#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167pub enum Guest {
168    /// No medium, nothing to run.
169    Off,
170    /// The installer is running. ~10 s, and **nothing narrates it**: PID 1
171    /// brings no network up (behaviour 20) and there is no serial console
172    /// (behaviour 13), so this window is silent by construction.
173    Installing,
174    /// The installer finished and the box booted its disk.
175    Installed,
176    /// The installer ran again, because the CD is still first in the boot order
177    /// (behaviour 17). A mock whose guest could not do this could not reproduce
178    /// the loop that cost an afternoon.
179    Looping { rounds: u32 },
180    /// The Ubuntu template's first boot: `dpkg lock-frontend` is held by
181    /// `unattended-upgrade-shutdown --wait-for-signal` for the LIFE of the boot,
182    /// so an `apt-get update` blocks rather than failing (behaviour 21).
183    TemplateFirstBoot,
184    /// PID 1 panicked. Invisible except on the framebuffer (behaviour 13).
185    Panicked,
186}
187
188/// One network interface of a server, as the TERRAFORM door asks for them:
189/// an index and a kind (`public` · `utility` · `private`). The addresses
190/// themselves stay on [`Server`] — one public and one utility per machine, from
191/// [`Addresses`] — because behaviour 12 is about the ADDRESS pool and not about
192/// how many rows a caller declared.
193///
194/// It exists because `upcloud_server` declares its interfaces and READS THEM
195/// BACK: holger's `outputs.tf` resolves `network_interface[0].ip_address` and
196/// `network_interface[1].ip_address`, so a machine that declared one interface
197/// and is answered with two has an output pointing at the wrong wire.
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct Iface {
200    pub index: u32,
201    pub kind: String,
202    /// **Behaviour 61.** `IPv4` or `IPv6`, as the interface asked for it.
203    /// UpCloud offers IPv6 (price 0); "no IPv6" is this estate's LAW, not the
204    /// provider's limit. The mock used to answer IPv4 whatever was asked, so a
205    /// declaration that broke the law would have passed here unseen.
206    pub family: String,
207}
208
209impl Iface {
210    /// What every caller in this crate had before the terraform door existed:
211    /// one public, one utility. Kept as the default so the plugin's servers are
212    /// unchanged by a door they never knock on.
213    pub fn default_pair() -> Vec<Iface> {
214        vec![
215            Iface { index: 1, kind: "public".into(), family: "IPv4".into() },
216            Iface { index: 2, kind: "utility".into(), family: "IPv4".into() },
217        ]
218    }
219}
220
221/// One firewall rule, every field a STRING, as the API sends them. See
222/// [`crate::tf`] for the two spellings a write arrives in.
223#[derive(Clone, Debug, Default, PartialEq, Eq)]
224pub struct Rule {
225    pub position: String,
226    pub direction: String,
227    pub action: String,
228    pub family: String,
229    pub protocol: String,
230    pub source_address_start: String,
231    pub source_address_end: String,
232    pub source_port_start: String,
233    pub source_port_end: String,
234    pub destination_address_start: String,
235    pub destination_address_end: String,
236    pub destination_port_start: String,
237    pub destination_port_end: String,
238    pub icmp_type: String,
239    pub comment: String,
240}
241
242#[derive(Clone, Debug)]
243pub struct Server {
244    pub uuid: String,
245    pub title: String,
246    pub hostname: String,
247    pub plan: String,
248    pub zone: String,
249    pub state: String,
250    pub labels: Vec<Label>,
251    pub devices: Vec<Device>,
252    pub boot_order: BootOrder,
253    pub remote_access_enabled: bool,
254    pub remote_access_password: String,
255    /// What the hypervisor is really listening on.
256    pub vnc_port: u16,
257    /// What the API SAYS it is listening on. These diverge on every stop/start
258    /// (behaviour 14) and only a `remote_access_enabled` no→yes toggle
259    /// reconciles them.
260    pub reported_vnc_port: u16,
261    /// **The console is a HOST and a port, and the toggle re-provisions both.**
262    /// MEASURED: after two PUTs the console came back as
263    /// `se-sto1.vnc.upcloud.com:60031` — a ZONE host, not the server's own
264    /// address. A client that re-reads only the port after the cure keeps
265    /// dialling the old place, which is the same defect one field further on.
266    pub vnc_host: String,
267    pub reported_vnc_host: String,
268    pub public_ip: String,
269    pub utility_ip: String,
270    pub guest: Guest,
271    /// **What this guest's DHCP client does with option 121.** The provider
272    /// offers the routes; the guest does or does not install them. Same shape as
273    /// [`crate::guest_clock::RtcInterpretation`], same reason.
274    pub dhcp_client: crate::net::DhcpClient,
275    /// **A new one on every re-image.** Which is expected, and which means every
276    /// automation that pushes to the forge meets
277    /// `REMOTE HOST IDENTIFICATION HAS CHANGED` on every bring-up.
278    pub ssh_host_key: String,
279    /// **How THIS guest's userland reads the RTC.** The hypervisor presents
280    /// correct UTC to every server; the front gets it right and the appliance
281    /// does not, and the difference is the guest's own software. Decided once,
282    /// at create, from [`Fault::GuestReadsRtcAsLocalTime`] — see
283    /// [`crate::guest_clock`] for why the clock itself is never skewed.
284    pub rtc: crate::guest_clock::RtcInterpretation,
285    pub created_ms: u64,
286    pub transition: Option<Transition>,
287    /// The interfaces this server was created with. See [`Iface`].
288    pub ifaces: Vec<Iface>,
289    /// The rule SET on this machine. Empty is not the same as absent: an empty
290    /// set is a machine with the firewall ON and nothing allowed, and the API
291    /// answers `[]` for it exactly as it does for a machine nobody ever wrote
292    /// rules to. Only `firewall` tells the two apart.
293    pub rules: Vec<Rule>,
294    /// `firewall: "on" | "off"`, as asked for at create.
295    pub firewall_on: bool,
296    /// `metadata: "yes" | "no"`, as asked for at create.
297    pub metadata: bool,
298    /// **The timezone the server was CREATED with, which is not the guest's
299    /// wall clock.** The detail renders this field because the provider reads
300    /// it back and a machine that asked for `Europe/Stockholm` and is answered
301    /// `UTC` is a permanent terraform diff. Behaviour 19 — the guest that reads
302    /// the RTC as local time — lives in [`crate::guest_clock`] and is untouched
303    /// by this: the API field says what was ASKED for, and the guest's clock is
304    /// still wrong for its own reasons.
305    pub timezone: String,
306    /// **Behaviour 60: can this guest's kernel hot-plug PCI?** Decided at
307    /// create from [`Fault::GuestKernelLacksHotplug`]; a property of the image.
308    pub hotplug: bool,
309    /// **Behaviour 50: the console has not moved yet.** Set by the "no"
310    /// half of a toggle; a "yes" before the clock passes it gets the OLD
311    /// endpoint back, which is why every working tool pauses between the two.
312    pub console_settles_at: Option<u64>,
313}
314
315impl Server {
316    /// What this guest's wall clock reads, minus the truth, in ms. Zero for a
317    /// guest whose userland knows the RTC is UTC.
318    pub fn clock_skew_ms(&self, unix_secs: i64) -> i64 {
319        self.rtc.skew_ms(&self.zone, unix_secs)
320    }
321
322    /// The DHCP offer this server's utility NIC received. Note it is the OFFER,
323    /// which is always complete: what varies is whether the guest took it.
324    pub fn dhcp_offer(&self) -> crate::net::DhcpOffer {
325        crate::net::DhcpOffer::for_address(&self.utility_ip)
326    }
327
328    /// The console endpoint the API reports, or `None` when remote access is
329    /// off — in which case the API does not answer a stale one, it answers
330    /// nothing, and a caller must be REFUSED BY NAME rather than handed the
331    /// last known host and port.
332    pub fn console(&self) -> Option<(String, u16)> {
333        self.remote_access_enabled
334            .then(|| (self.reported_vnc_host.clone(), self.reported_vnc_port))
335    }
336}
337
338impl Server {
339    pub fn label(&self, key: &str) -> Option<&str> {
340        self.labels.iter().find(|l| l.key == key).map(|l| l.value.as_str())
341    }
342    pub fn boot_disk(&self) -> Option<&Device> {
343        self.devices.iter().find(|d| d.boot_disk)
344    }
345}
346
347/// How a caller reached the box it is checking the host key of.
348///
349/// Three, and they must agree. `Name` resolves through DNS to the front and
350/// then through the DNAT; `Front` skips the DNS; `Direct` skips the DNAT too
351/// and talks to the appliance's own public address. One key on three paths is
352/// a re-imaged machine; one path disagreeing is a hijacked name.
353#[derive(Clone, Copy, PartialEq, Eq, Debug)]
354pub enum HostKeyPath {
355    Name,
356    Front,
357    Direct,
358}
359
360impl HostKeyPath {
361    pub fn parse(s: &str) -> Option<HostKeyPath> {
362        match s {
363            "name" => Some(HostKeyPath::Name),
364            "front" => Some(HostKeyPath::Front),
365            "direct" => Some(HostKeyPath::Direct),
366            _ => None,
367        }
368    }
369    pub const ALL: [HostKeyPath; 3] = [HostKeyPath::Name, HostKeyPath::Front, HostKeyPath::Direct];
370    pub fn name(self) -> &'static str {
371        match self {
372            HostKeyPath::Name => "name",
373            HostKeyPath::Front => "front",
374            HostKeyPath::Direct => "direct",
375        }
376    }
377}
378
379/// A scheduled state change.
380#[derive(Clone, Debug)]
381pub struct Transition {
382    pub to: String,
383    pub at_ms: u64,
384    pub then: After,
385}
386
387#[derive(Clone, Debug, PartialEq, Eq)]
388pub enum After {
389    Nothing,
390    /// Free the object (the delete completes).
391    Vanish,
392    /// The guest starts running — installer, template first boot, or the disk.
393    GuestBoots,
394    /// The plan change completed; mint the `Resize Backup` off `origin`.
395    MintResizeBackup { origin: String },
396    /// **The upload finished and the SYNC begins.** The import object flips to
397    /// `completed` here — with its `completed_ms` — while the storage enters
398    /// `syncing` and stays there for [`Timings::storage_sync_lo_ms`] to
399    /// [`Timings::storage_sync_hi_ms`]. Two clocks, one object, and the second
400    /// one starts only when the first has stopped.
401    BeginSync,
402    /// Go `online` this many ms after arriving. The second leg of a clone's
403    /// `maintenance` → `syncing` → `online`, which needs two hops and a
404    /// transition carries one.
405    OnlineIn { ms: u64 },
406    /// **The installer powered the box off (behaviour 46).** The guest is
407    /// `Installed`. The transition carrying this is HELD while a real machine
408    /// behind the server still runs: the API reports UpCloud's time, but it
409    /// never says `stopped` over a running VM.
410    GuestPoweredOff,
411    /// **The installer's brief `started` is over; the pass runs in
412    /// `maintenance`** and ends `stopped` (via [`After::GuestPoweredOff`])
413    /// after `left_ms` more.
414    InstallPass { left_ms: u64 },
415    /// **The rebooting medium's pass ended (behaviour 69): the guest reboots.**
416    /// The server stays `started`. The CD still loaded and first → the
417    /// installer runs again (17) and another pass is scheduled; otherwise the
418    /// disk boots and the guest is `Installed`.
419    MediumRebooted,
420    /// **A real guest powered itself off while the server read `started`.**
421    /// Noticed `poweroff_notice_ms` later; the guest is `Off`.
422    GuestGone,
423    /// **A size grow finished (behaviour 55).** Marks the `maintenance` of a
424    /// grow, which a filesystem resize may follow at once: 5.44.1 sends
425    /// `POST /storage/{uuid}/resize` straight after the PUT, without waiting,
426    /// against the live account.
427    GrowDone,
428    /// **A fresh `POST /storage` settling (behaviour 66).** Nothing happens on
429    /// arrival; it marks the create's `maintenance` as the one an import may be
430    /// started in.
431    Created,
432}
433
434// ── the address pools ────────────────────────────────────────────────────────
435
436/// **RFC 5737's three documentation blocks** (`/24` each), the ONLY place a
437/// mock public address may come from: not routed on the internet, so nothing
438/// the mock hands out can ever be someone else's machine.
439pub const TEST_NET: [&str; 3] = ["192.0.2", "198.51.100", "203.0.113"];
440
441/// Is `ip` inside one of the [`TEST_NET`] blocks?
442pub fn is_test_net(ip: &str) -> bool {
443    match ip.parse::<std::net::Ipv4Addr>() {
444        Ok(a) => {
445            let o = a.octets();
446            TEST_NET.contains(&format!("{}.{}.{}", o[0], o[1], o[2]).as_str())
447        }
448        Err(_) => false,
449    }
450}
451
452/// Is `ip` one the utility pool hands out: `10.13.8.96–120` or `10.13.12.96–120`?
453pub fn is_utility_pool(ip: &str) -> bool {
454    match ip.parse::<std::net::Ipv4Addr>() {
455        Ok(a) => {
456            let o = a.octets();
457            o[0] == 10 && o[1] == 13 && (o[2] == 8 || o[2] == 12) && (96..=120).contains(&o[3])
458        }
459        Err(_) => false,
460    }
461}
462
463/// **Behaviour 12: addresses move.**
464///
465/// The appliance got `10.13.8.101` one re-lay and `10.13.8.99` the next, with
466/// the twin holding the other; the public address changed every single time.
467/// Code that held a literal went stale exactly that way, so the mock shuffles
468/// both pools on every lay and hands them out in the shuffled order. A consumer
469/// that pins an address fails here on the second lay instead of in production
470/// on the second re-lay.
471///
472/// **The public pool is RFC 5737 TEST-NET, and nothing else — ever.** It was a
473/// list of REAL UpCloud addresses (our estate's of 2026-09-15, and others), and
474/// one of them had port 22 open on a machine that is not ours: every mock step
475/// that connects to a handed-out address — verify, the ssh to the front, the
476/// re-image's banner check — went out onto the internet, to a stranger. A
477/// TEST-NET address is not routed, so a mock that connects to one fails
478/// locally instead of reaching someone. [`TEST_NET`] is the check, and the
479/// tests hold every address the estate can hand out to it.
480///
481/// The utility pool is RFC 1918 (`10.13.8.96–120`, `10.13.12.96–120`, across
482/// both /22s for option 121, behaviour 29), disjoint from the networks of the
483/// box the rig runs on.
484pub struct Addresses {
485    utility: Vec<String>,
486    public: Vec<String>,
487    next_utility: usize,
488    next_public: usize,
489    lays: u64,
490}
491
492impl Addresses {
493    pub fn new(seed: u64) -> Addresses {
494        let mut a = Addresses {
495            // **Across BOTH utility prefixes**, because that is what makes
496            // option 121 load-bearing: the appliance and the front land in
497            // different /22s and the route between them arrives only as a
498            // classless static route. A pool inside one prefix would let a
499            // guest that ignores the option work perfectly, and the bug that
500            // cost an afternoon would be unreachable here.
501            utility: (96..=120)
502                .map(|n| format!("10.13.8.{n}"))
503                .chain((96..=120).map(|n| format!("10.13.12.{n}")))
504                .collect(),
505            // Eight from each TEST-NET block: enough that two lays almost
506            // never hand the same server the same address (behaviour 12).
507            public: TEST_NET
508                .iter()
509                .flat_map(|net| (10..18).map(move |h| format!("{net}.{h}")))
510                .collect(),
511            next_utility: 0,
512            next_public: 0,
513            lays: 0,
514        };
515        a.relay(seed);
516        a
517    }
518
519    /// A new lay of the estate: reshuffle, hand out from the top again.
520    pub fn relay(&mut self, seed: u64) {
521        self.lays += 1;
522        let mut r = SplitMix64::derive(seed, &format!("addresses/lay/{}", self.lays));
523        r.shuffle(&mut self.utility);
524        r.shuffle(&mut self.public);
525        self.next_utility = 0;
526        self.next_public = 0;
527    }
528
529    pub fn lays(&self) -> u64 {
530        self.lays
531    }
532
533    pub fn take_utility(&mut self) -> String {
534        let v = self.utility[self.next_utility % self.utility.len()].clone();
535        self.next_utility += 1;
536        v
537    }
538
539    pub fn take_public(&mut self) -> String {
540        let v = self.public[self.next_public % self.public.len()].clone();
541        self.next_public += 1;
542        v
543    }
544}
545
546// ── the refusals ─────────────────────────────────────────────────────────────
547
548/// A refusal the mock can answer with. `code` is UpCloud's `error_code`.
549#[derive(Clone, Debug, PartialEq, Eq)]
550pub struct Refusal {
551    pub status: u16,
552    pub code: &'static str,
553    pub message: String,
554}
555
556impl Refusal {
557    pub fn new(status: u16, code: &'static str, message: impl Into<String>) -> Refusal {
558        Refusal { status, code, message: message.into() }
559    }
560}
561
562pub type Answer<T> = Result<T, Refusal>;
563
564// ── the estate ───────────────────────────────────────────────────────────────
565
566pub struct Estate {
567    pub clock: Clock,
568    pub timings: Timings,
569    pub faults: Faults,
570    pub zone: String,
571    /// What the mock says its own upload sessions live at. `serve` fills it in
572    /// with the socket it bound, so a caller that FOLLOWS the
573    /// `direct_upload_url` from the import reply reaches the mock instead of
574    /// the internet. Empty renders the real provider's shape
575    /// (`https://<zone>.img.upcloud.com/uploader/session/<uuid>`), which is what
576    /// a parser should be tested against.
577    pub upload_base: String,
578    /// Every destination-NAT rule in the estate: the front's `:2222` to the
579    /// appliance, and anything else a lay installs. Held here because the
580    /// hairpin question ("can the box holding the rule use it") can only be
581    /// answered by something that knows all of them.
582    pub dnat: Vec<crate::net::Dnat>,
583    /// The account's MaxIOPS quota in GiB (behaviour 44). MEASURED for yvra.
584    pub maxiops_quota_gib: u64,
585    /// **What is behind a server.** [`VirtualGuest`] by default, which runs
586    /// nothing and leaves every answer to the state machine below. A real
587    /// engine (the `draupnir-guest` crate's `DraupnirGuest`, injected by the
588    /// `mock-upcloud-kvm` binary) boots a QEMU at every `started`, and the
589    /// server's `state` then follows that machine: see [`Estate::reap`].
590    pub engine: Arc<dyn GuestEngine>,
591    servers: BTreeMap<String, Server>,
592    storages: BTreeMap<String, Storage>,
593    /// Servers and storages that ONCE existed. Behaviour 1 needs this: the
594    /// firewall endpoint's 403 is what a DELETED server answers, and a mock that
595    /// forgot its dead could not tell that from a uuid nobody ever minted —
596    /// which, as it happens, is exactly the ambiguity that stopped Terraform.
597    tombstones: BTreeMap<String, &'static str>,
598    addresses: Addresses,
599    ids: SplitMix64,
600    seed: u64,
601    correlations: u64,
602}
603
604impl Estate {
605    pub fn new(clock: Clock, faults: Faults, seed: u64) -> Estate {
606        let mut e = Estate {
607            clock,
608            timings: Timings::default(),
609            faults,
610            zone: "se-sto1".to_string(),
611            upload_base: String::new(),
612            dnat: Vec::new(),
613            maxiops_quota_gib: 10_240,
614            engine: Arc::new(VirtualGuest::default()),
615            servers: BTreeMap::new(),
616            storages: BTreeMap::new(),
617            tombstones: BTreeMap::new(),
618            addresses: Addresses::new(seed),
619            ids: SplitMix64::derive(seed, "uuids"),
620            seed,
621            correlations: 0,
622        };
623        e.seed_public_templates();
624        e
625    }
626
627    pub fn seed(&self) -> u64 {
628        self.seed
629    }
630
631    /// The same estate with a real machine behind every server.
632    pub fn with_engine(mut self, engine: Arc<dyn GuestEngine>) -> Estate {
633        self.engine = engine;
634        self
635    }
636
637    /// **The server's state follows its machine.** A server that reads
638    /// `started` while its QEMU is gone is a health line that cannot fail —
639    /// and the installer powers the box off ~2–3 s after INSTALL-OK, so this is
640    /// the ordinary case, not a crash. Only a server the engine has a machine
641    /// for is touched (`running` is `None` otherwise), so the default engine
642    /// changes nothing.
643    pub fn reap(&mut self) {
644        let gone: Vec<String> = self
645            .servers
646            .values()
647            .filter(|s| s.state == "started")
648            .filter(|s| self.engine.running(&s.uuid) == Some(false))
649            .map(|s| s.uuid.clone())
650            .collect();
651        // **The timing rule (lane T13, which owns this from here).** The API
652        // reports UpCloud's time, not the VM's: a guest that powers itself off
653        // is NOTICED `poweroff_notice_ms` later, so the server keeps reading
654        // `started` for that long (the API may run behind the machine, never
655        // ahead of it — the other direction is held in `settle_server`). A
656        // server already on its way somewhere keeps its own transition.
657        let at = self.clock.now_ms() + self.timings.poweroff_notice_ms;
658        for uuid in gone {
659            if let Some(s) = self.servers.get_mut(&uuid) {
660                if s.transition.is_none() {
661                    s.transition = Some(Transition { to: "stopped".into(), at_ms: at, then: After::GuestGone });
662                }
663            }
664        }
665    }
666
667    /// The machine the engine is asked to power on for this server, from the
668    /// records as they are NOW: sizes from the storage records (a resize
669    /// changes those, not the device rows), the tray from the cdrom device.
670    fn machine_for(&self, uuid: &str) -> Option<Machine> {
671        let s = self.servers.get(uuid)?;
672        let mut devs: Vec<&Device> = s.devices.iter().filter(|d| d.kind == "disk").collect();
673        devs.sort_by_key(|d| !d.boot_disk);
674        let disks = devs
675            .iter()
676            .map(|d| MachineDisk {
677                storage: d.storage.clone(),
678                size_gib: self.storages.get(&d.storage).map(|st| st.size_gib).unwrap_or(d.storage_size),
679            })
680            .collect();
681        let medium = s
682            .devices
683            .iter()
684            .find(|d| d.kind == "cdrom" && !d.storage.is_empty())
685            .map(|d| d.storage.clone());
686        Some(Machine {
687            server: uuid.to_string(),
688            mem_mb: crate::render::memory_of(&s.plan),
689            cores: crate::render::cores_of(&s.plan),
690            disks,
691            cdrom_first: s.boot_order.cdrom_first() && medium.is_some(),
692            medium,
693            rtc_skew_seconds: crate::kvm::GuestSpec::new("", "").rtc_skew_seconds,
694        })
695    }
696
697    /// The public images UpCloud publishes. They matter for one reason: a bare
698    /// `GET /1.3/storage` lists them — thousands of rows, none of them the
699    /// account's — which is why `gunnar/deploy/upcloud` reads
700    /// `/1.3/storage/private` instead. The mock carries a handful so that
701    /// difference is visible rather than theoretical.
702    fn seed_public_templates(&mut self) {
703        for title in [
704            "Ubuntu Server 24.04 LTS (Noble Numbat)",
705            "Ubuntu Server 22.04 LTS (Jammy Jellyfish)",
706            "Debian GNU/Linux 12 (Bookworm)",
707            "AlmaLinux 9",
708        ] {
709            let uuid = self.mint_uuid();
710            self.storages.insert(
711                uuid.clone(),
712                Storage {
713                    uuid,
714                    title: title.to_string(),
715                    size_gib: 10,
716                    tier: "maxiops".into(),
717                    zone: self.zone.clone(),
718                    state: "online".into(),
719                    kind: StorageKind::Template,
720                    labels: vec![],
721                    origin: None,
722                    created_ms: 0,
723                    import: None,
724                    transition: None,
725                },
726            );
727        }
728    }
729
730    fn mint_uuid(&mut self) -> String {
731        // UpCloud's uuids are `01234567-89ab-cdef-0123-456789abcdef`, minted
732        // here from the run's seed so the same seed replays the same ids and a
733        // failure signature can name one.
734        let a = self.ids.next_u64();
735        let b = self.ids.next_u64();
736        format!(
737            "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
738            (a >> 32) as u32,
739            (a >> 16) as u16,
740            a as u16,
741            (b >> 48) as u16,
742            b & 0xffff_ffff_ffff
743        )
744    }
745
746    pub fn next_correlation_id(&mut self) -> String {
747        self.correlations += 1;
748        format!("{:016x}{:08x}", self.ids.next_u64(), self.correlations as u32)
749    }
750
751    // ── time ────────────────────────────────────────────────────────────────
752
753    /// Move every object whose transition is due, then — when the clock is
754    /// virtual — push time part-way toward the next one.
755    ///
756    /// Part-way, not all the way, on purpose: a client that polls must poll
757    /// several times, as it does against the real provider. Advancing straight
758    /// to the next deadline would make every transition complete on the second
759    /// read and silently excuse a client with no loop at all.
760    pub fn tick(&mut self) {
761        self.settle();
762        if self.clock.speed_milli() == 0 {
763            if let Some(next) = self.next_deadline() {
764                let now = self.clock.now_ms();
765                if next > now {
766                    self.clock.advance_ms(((next - now) / 2).max(1));
767                }
768            }
769        }
770    }
771
772    /// Jump the virtual clock to the moment nothing is pending any more.
773    ///
774    /// Only two callers, and both are honest about why: a `restart` is a stop
775    /// and a start and the stop must COMPLETE in between, and a test that is
776    /// not testing the poll loop says so by calling this. It is a no-op at real
777    /// speed, where waiting is the only way through — which is the right
778    /// asymmetry: virtual time may skip, real time may not.
779    pub fn run_to_quiet(&mut self) {
780        if self.clock.speed_milli() != 0 {
781            self.settle();
782            return;
783        }
784        for _ in 0..1000 {
785            self.settle();
786            match self.next_deadline() {
787                None => return,
788                Some(at) => {
789                    let now = self.clock.now_ms();
790                    self.clock.advance_ms(at.saturating_sub(now).max(1));
791                }
792            }
793        }
794    }
795
796    fn next_deadline(&self) -> Option<u64> {
797        let a = self.servers.values().filter_map(|s| s.transition.as_ref().map(|t| t.at_ms));
798        let b = self.storages.values().filter_map(|s| s.transition.as_ref().map(|t| t.at_ms));
799        // The console settle (behaviour 50) is a deadline a poller should be
800        // able to wait out at virtual speed; only a FUTURE one, or
801        // `run_to_quiet` would spin on it.
802        let now = self.clock.now_ms();
803        let c = self.servers.values().filter_map(|s| s.console_settles_at).filter(|t| *t > now);
804        a.chain(b).chain(c).min()
805    }
806
807    /// Apply every due transition. Idempotent, and the only writer of a state
808    /// that nobody asked for.
809    pub fn settle(&mut self) {
810        self.reap();
811        loop {
812            let now = self.clock.now_ms();
813            let due_server = self
814                .servers
815                .values()
816                .find(|s| s.transition.as_ref().is_some_and(|t| t.at_ms <= now))
817                .map(|s| s.uuid.clone());
818            if let Some(uuid) = due_server {
819                self.settle_server(&uuid);
820                continue;
821            }
822            let due_storage = self
823                .storages
824                .values()
825                .find(|s| s.transition.as_ref().is_some_and(|t| t.at_ms <= now))
826                .map(|s| s.uuid.clone());
827            if let Some(uuid) = due_storage {
828                self.settle_storage(&uuid);
829                continue;
830            }
831            return;
832        }
833    }
834
835    fn settle_server(&mut self, uuid: &str) {
836        let Some(s) = self.servers.get_mut(uuid) else { return };
837        let Some(t) = s.transition.take() else { return };
838        // **Chain from the moment the transition was DUE, not the moment it is
839        // settled.** A caller whose clock jumped (a 10 s poll, a virtual sleep)
840        // settles late; a follow-on scheduled from "now" would open a short
841        // window — the installer's ~83 ms `started` — AT the jump, and the next
842        // read would always see it however coarse the poll. From `due`, a
843        // window that closed during the jump is settled in the same pass and
844        // never read: virtual time replays like real time (T14's flow test
845        // a_medium_that_powers_off_may_end_its_pass_without_ever_being_read_started).
846        let due = t.at_ms;
847        // **The timing rule, one direction of it.** A modelled `stopped` that
848        // a real machine contradicts (it is still running) waits for the
849        // machine; the API may run behind the VM, never ahead of it.
850        if t.then == After::GuestPoweredOff && self.engine.running(uuid) == Some(true) {
851            let at = self.clock.now_ms() + self.timings.poweroff_notice_ms;
852            if let Some(s) = self.servers.get_mut(uuid) {
853                s.transition = Some(Transition { at_ms: at, ..t });
854            }
855            return;
856        }
857        let Some(s) = self.servers.get_mut(uuid) else { return };
858        s.state = t.to.clone();
859        // `stopped` means no machine. A soft stop pressed the power button when
860        // it was asked; a guest that has not acted on it by now is killed, which
861        // is what the provider's stop timeout does too.
862        if t.to == "stopped" {
863            self.engine.power_off(uuid, true);
864        }
865        match t.then {
866            After::Nothing => {}
867            After::Vanish => {
868                let s = self.servers.remove(uuid).expect("just had it");
869                self.tombstones.insert(uuid.to_string(), "server");
870                // `?storages=1` took the attached disks with it. A BACKUP is
871                // never attached and so never goes this way — which is exactly
872                // how a `Resize Backup` outlives the server it came from and
873                // keeps billing (behaviour 9).
874                let dead: Vec<String> = s
875                    .devices
876                    .iter()
877                    .filter(|d| {
878                        self.storages
879                            .get(&d.storage)
880                            .is_some_and(|st| st.kind != StorageKind::Backup && st.kind != StorageKind::Template)
881                    })
882                    .map(|d| d.storage.clone())
883                    .collect();
884                for u in dead {
885                    self.storages.remove(&u);
886                    self.engine.forget_storage(&u);
887                    self.tombstones.insert(u, "storage");
888                }
889                return;
890            }
891            After::GuestBoots => {
892                let looping = self.faults.fires(Fault::InstallerLoop);
893                let s = self.servers.get_mut(uuid).expect("just had it");
894                // A MEDIUM in the tray, not a cdrom DEVICE: an eject leaves the
895                // device with an empty `storage` (L52), and `cdrom,disk` with
896                // an empty tray falls through to the disk (L54). Asking for
897                // the device made every disk boot after an eject an installer
898                // pass, held in `maintenance` while the appliance served gRPC.
899                let has_cdrom = s.devices.iter().any(|d| d.kind == "cdrom" && !d.storage.is_empty());
900                s.guest = match (s.boot_order.cdrom_first(), has_cdrom) {
901                    // Behaviour 17: the CD is still first, so the installer runs
902                    // again. Forever, because nothing in the guest changes the
903                    // boot order — only `cdrom/eject` from outside ends it.
904                    (true, true) if looping => Guest::Looping { rounds: 1 },
905                    (true, true) => Guest::Installing,
906                    (_, _) if s.boot_disk().is_some() => Guest::TemplateFirstBoot,
907                    _ => Guest::Off,
908                };
909                // **A re-image mints a new SSH host key. Every time.** Expected,
910                // and the reason every automation that pushes to the forge meets
911                // `REMOTE HOST IDENTIFICATION HAS CHANGED` on every bring-up.
912                let now = self.clock.now_ms();
913                let mut kr = SplitMix64::derive(self.seed, &format!("hostkey/{uuid}/{now}"));
914                let key = format!("SHA256:{:016x}{:016x}", kr.next_u64(), kr.next_u64());
915                let lo = self.timings.guest_install_uart_lo_ms;
916                let hi = self.timings.guest_install_uart_hi_ms;
917                let install_ms = kr.range(lo, hi);
918                let at = self.clock.now_ms() + install_ms;
919                let s = self.servers.get_mut(uuid).expect("just had it");
920                // An install mints them, and so does an ordinary first boot —
921                // cloud-init generates host keys on a template's first boot for
922                // exactly the same reason. Either way the box that comes up is
923                // not the box that went down, as far as `known_hosts` is
924                // concerned.
925                if matches!(s.guest, Guest::Installing | Guest::Looping { .. } | Guest::TemplateFirstBoot) {
926                    s.ssh_host_key = key;
927                }
928                // **Behaviour 46, corrected: an installer that powers the box off
929                // reads `started` BRIEFLY, then `maintenance` for the pass, then
930                // `stopped`.** MEASURED 2026-09-21 on the live re-image (receipt:
931                // `started` read 3.67 s after the start, `maintenance` 83 ms
932                // later, `stopped` 153.5 s after the start). The 2026-09-19
933                // "never `started`" was a 10 s poll that began at t+10 s. The
934                // guest's own install is 2–5 s (`at`, the UART clock); the API
935                // reports the PROVIDER's pass, and that is the floor even when a
936                // real guest behind the mock finished early. A real guest that
937                // runs longer holds the `stopped` back (`After::GuestPoweredOff`).
938                // **Behaviour 69, the REBOOTING medium** (fault
939                // `installer-reboots`): no power-off, so the API reads
940                // `started` for the whole 900–1100 s pass, and the pass ends
941                // in a REBOOT (see `After::MediumRebooted`).
942                let reboots = matches!(s.guest, Guest::Installing) && self.faults.fires(Fault::InstallerReboots);
943                if reboots {
944                    let pass = kr.range(self.timings.reboot_pass_lo_ms, self.timings.reboot_pass_hi_ms);
945                    let s = self.servers.get_mut(uuid).expect("just had it");
946                    s.transition = Some(Transition {
947                        to: "started".into(),
948                        at_ms: due + pass,
949                        then: After::MediumRebooted,
950                    });
951                } else if matches!(s.guest, Guest::Installing) {
952                    let _ = at;
953                    let pass = kr.range(self.timings.install_pass_lo_ms, self.timings.install_pass_hi_ms);
954                    let window = self.timings.installer_started_window_ms;
955                    let left = pass.saturating_sub(self.timings.installer_started_ms + window).max(1);
956                    let s = self.servers.get_mut(uuid).expect("just had it");
957                    // `started` stays, for the window only.
958                    s.transition = Some(Transition {
959                        to: "maintenance".into(),
960                        at_ms: due + window,
961                        then: After::InstallPass { left_ms: left },
962                    });
963                }
964                // **The machine.** `started` is when the provider's VM really
965                // starts, so this is where a real engine boots one. A refusal
966                // (a clause the engine will not boot without, a host with no
967                // KVM) leaves the server `stopped`, never `started` over nothing.
968                if let Some(m) = self.machine_for(uuid) {
969                    if let Err(why) = self.engine.power_on(&m) {
970                        eprintln!("mock-upcloud  server {uuid}: the machine did not start: {why}");
971                        let s = self.servers.get_mut(uuid).expect("just had it");
972                        s.state = "stopped".into();
973                        s.guest = Guest::Off;
974                        s.transition = None;
975                    } else if let Some((host, port)) = self.engine.console(uuid) {
976                        // **The console is the guest's real VNC.** The machine
977                        // is where the hypervisor really listens (behaviour 14's
978                        // `vnc_*`); the REPORTED pair follows it only if this
979                        // start did not leave it stale — `start_server` set the
980                        // two equal exactly when the stale-port fault did not
981                        // fire, so the defect stays on top of a real console.
982                        let s = self.servers.get_mut(uuid).expect("just had it");
983                        let follows = s.reported_vnc_port == s.vnc_port && s.reported_vnc_host == s.vnc_host;
984                        s.vnc_host = host;
985                        s.vnc_port = port;
986                        if follows {
987                            s.reported_vnc_host = s.vnc_host.clone();
988                            s.reported_vnc_port = s.vnc_port;
989                        }
990                    }
991                }
992            }
993            After::MintResizeBackup { origin } => {
994                let _ = self.mint_resize_backup(&origin);
995            }
996            // A server has no import and no sync: those are a STORAGE's two
997            // clocks. Named rather than swallowed, so a new `After` that a
998            // server should honour fails to compile here instead of silently
999            // doing nothing.
1000            After::MediumRebooted => {
1001                let now = self.clock.now_ms();
1002                let mut kr = SplitMix64::derive(self.seed, &format!("reboot/{uuid}/{now}"));
1003                let pass = kr.range(self.timings.reboot_pass_lo_ms, self.timings.reboot_pass_hi_ms);
1004                let key = format!("SHA256:{:016x}{:016x}", kr.next_u64(), kr.next_u64());
1005                if let Some(s) = self.servers.get_mut(uuid) {
1006                    let cd_again = s.boot_order.cdrom_first()
1007                        && s.devices.iter().any(|d| d.kind == "cdrom" && !d.storage.is_empty());
1008                    if cd_again {
1009                        // Behaviour 17: the firmware finds the CD first again
1010                        // and the installer runs again — a new pass, a new key.
1011                        let rounds = match s.guest {
1012                            Guest::Looping { rounds } => rounds + 1,
1013                            _ => 1,
1014                        };
1015                        s.guest = Guest::Looping { rounds };
1016                        s.ssh_host_key = key;
1017                        s.transition = Some(Transition { to: "started".into(), at_ms: due + pass, then: After::MediumRebooted });
1018                    } else {
1019                        // No medium (ejected) or the disk first: the installed
1020                        // system boots, and the server simply stays `started`.
1021                        s.guest = Guest::Installed;
1022                    }
1023                }
1024            }
1025            After::InstallPass { left_ms } => {
1026                let at = due + left_ms;
1027                if let Some(s) = self.servers.get_mut(uuid) {
1028                    s.transition = Some(Transition { to: "stopped".into(), at_ms: at, then: After::GuestPoweredOff });
1029                }
1030            }
1031            After::BeginSync | After::OnlineIn { .. } | After::GrowDone | After::Created => {}
1032            After::GuestPoweredOff => {
1033                if let Some(s) = self.servers.get_mut(uuid) {
1034                    s.guest = Guest::Installed;
1035                }
1036            }
1037            After::GuestGone => {
1038                if let Some(s) = self.servers.get_mut(uuid) {
1039                    s.guest = Guest::Off;
1040                }
1041            }
1042        }
1043    }
1044
1045    fn settle_storage(&mut self, uuid: &str) {
1046        let Some(s) = self.storages.get_mut(uuid) else { return };
1047        let Some(t) = s.transition.take() else { return };
1048        s.state = t.to.clone();
1049        match t.then {
1050            After::Vanish => {
1051                self.storages.remove(uuid);
1052                self.engine.forget_storage(uuid);
1053                self.tombstones.insert(uuid.to_string(), "storage");
1054            }
1055            // **The two clocks part company here.** Every byte has arrived and
1056            // been checksummed — the import says `completed`, with a
1057            // `completed_ms` five seconds after its `created` — and the storage
1058            // now enters the hundred-and-something seconds of `syncing` during
1059            // which neither side is doing anything the caller can see.
1060            After::BeginSync => {
1061                let now = self.clock.now_ms();
1062                let over_budget = self.faults.fires(Fault::SyncExceedsBudget);
1063                let failed = self.faults.fires(Fault::ImportFailed);
1064                let (lo, hi) = (self.timings.storage_sync_lo_ms, self.timings.storage_sync_hi_ms);
1065                let tail_ms = self.timings.sync_tail_maintenance_ms;
1066                let mut r = SplitMix64::derive(self.seed, &format!("sync/{uuid}"));
1067                // The caller polls with a 1200 s budget. This fault steps over
1068                // it — deliberately, because a timeout that has never fired is
1069                // a timeout nobody has read the handler of.
1070                let ms = if over_budget { 1_300_000 } else { r.range(lo, hi) };
1071                let s = self.storages.get_mut(uuid).expect("just settled");
1072                if let Some(im) = s.import.as_mut() {
1073                    if failed {
1074                        im.state = "failed".into();
1075                        im.completed_ms = Some(now);
1076                        im.error_code = Some("IMPORT_FAILED".into());
1077                        im.error_message =
1078                            Some("the uploaded image could not be written to the storage".into());
1079                    } else {
1080                        im.state = "completed".into();
1081                        im.completed_ms = Some(now);
1082                    }
1083                }
1084                if failed {
1085                    s.state = "error".into();
1086                } else {
1087                    // **Behaviour 52.** syncing → maintenance → online, MEASURED as
1088                    // a sequence (clone_probe: "maintenance, syncing, maintenance,
1089                    // online"). The split is not measured, so the tail is carved
1090                    // out of the sync range and the total stays the measured one.
1091                    let tail = tail_ms.min(ms.saturating_sub(1));
1092                    s.transition = Some(Transition {
1093                        to: "maintenance".into(),
1094                        at_ms: now + ms - tail,
1095                        then: After::OnlineIn { ms: tail },
1096                    });
1097                }
1098            }
1099            After::OnlineIn { ms } => {
1100                let at = self.clock.now_ms() + ms;
1101                if let Some(s) = self.storages.get_mut(uuid) {
1102                    s.transition = Some(Transition { to: "online".into(), at_ms: at, then: After::Nothing });
1103                }
1104            }
1105            _ => {}
1106        }
1107    }
1108
1109    // ── behaviour 9: the Resize Backup ──────────────────────────────────────
1110
1111    /// **Behaviour 9.** A `stop-resize-start` leaves this behind: type `backup`,
1112    /// detached, `origin` naming the volume that was resized — which may already
1113    /// be gone — and carrying the estate's own labels (`site`, `role`, `repo`,
1114    /// `volume`) but NOT the word "gunnar" anywhere in its title.
1115    ///
1116    /// That last clause is the defect: the cleanup filtered by title prefix, and
1117    /// a title-prefix filter is BLIND to this row. It billed maxiops for it at
1118    /// about €0.20 per GB-month, forever, and nothing listed it.
1119    fn mint_resize_backup(&mut self, origin: &str) -> String {
1120        let (size, labels, zone) = match self.storages.get(origin) {
1121            Some(o) => (o.size_gib, o.labels.clone(), o.zone.clone()),
1122            // The origin is already gone. The backup is minted anyway — this is
1123            // the orphan, and it is the normal case after the server that owned
1124            // the volume has been destroyed.
1125            None => (20, vec![], self.zone.clone()),
1126        };
1127        // The brief's hypothesis, by name: a backup the provider titled ITSELF
1128        // and labelled with nothing. The default is the measurement (labels
1129        // copied); see [`Fault::ResizeBackupUnlabelled`].
1130        let labels = if self.faults.fires(Fault::ResizeBackupUnlabelled) { vec![] } else { labels };
1131        let now = self.clock.now_ms();
1132        let uuid = self.mint_uuid();
1133        let orphan = self.faults.fires(Fault::OrphanResizeBackup);
1134        self.storages.insert(
1135            uuid.clone(),
1136            Storage {
1137                uuid: uuid.clone(),
1138                // The auto-title. No product name, no site name, no "gunnar".
1139                title: format!("Resize Backup {}", now / 1000),
1140                size_gib: size,
1141                tier: "maxiops".into(),
1142                zone,
1143                state: "online".into(),
1144                kind: StorageKind::Backup,
1145                labels,
1146                origin: Some(if orphan {
1147                    // A uuid that resolves to nothing: the origin volume was
1148                    // deleted with its server before anyone looked.
1149                    format!("{}-gone", &origin[..origin.len().min(30)])
1150                } else {
1151                    origin.to_string()
1152                }),
1153                created_ms: now,
1154                import: None,
1155                transition: None,
1156            },
1157        );
1158        uuid
1159    }
1160
1161    /// **Behaviour 34 — `POST /1.3/storage/{uuid}/resize`, the door that
1162    /// actually left the 44 GB behind.**
1163    ///
1164    /// Behaviour 9 modelled the `Resize Backup` on a PLAN change, which is a
1165    /// door this estate never opens. The one it does open is this: `cargo xtask
1166    /// grow` grows the twin's volume with `PUT /storage/{uuid}` (behaviour 22)
1167    /// and then asks the provider to grow the LAST PARTITION and the xfs inside
1168    /// it with `POST /storage/{uuid}/resize`. The provider takes a backup FIRST
1169    /// and hands it back in the reply as `resize_backup` — the whole storage
1170    /// object, uuid and all — and nothing in the estate deletes it afterwards.
1171    /// MEASURED 2026-09-20: a 44 GB `Resize Backup` on the live account whose
1172    /// `origin` was the twin's data volume, billed at maxiops since 2026-09-19
1173    /// 21:54:06Z, and `upcloud-orphans` said "no orphans" over it.
1174    ///
1175    /// The backup is the resized volume's size (that is what 44 GB was: the
1176    /// twin's volume after its growth), carries the volume's labels unless
1177    /// [`Fault::ResizeBackupUnlabelled`] is armed, and its `origin` is the
1178    /// volume — or a gone uuid under [`Fault::OrphanResizeBackup`]. The volume
1179    /// itself goes `maintenance` for the resize and comes back `online`, so a
1180    /// caller that does not poll gets the same lesson every other write here
1181    /// teaches.
1182    ///
1183    /// Refused on a volume attached to a server that is not `stopped`
1184    /// (`SERVER_STATE_ILLEGAL`; the estate stops the twin first and says so in
1185    /// its journal) and on a volume that is not `online`.
1186    pub fn resize_filesystem(&mut self, uuid: &str) -> Answer<String> {
1187        self.refuse_if_write_unavailable()?;
1188        let Some(s) = self.storages.get(uuid) else {
1189            return Err(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {uuid} not found")));
1190        };
1191        // Online, or still in the `maintenance` of a size grow: 5.44.1 sends
1192        // this straight after the PUT, without waiting, and it works against
1193        // the live account (behaviour 55's other half).
1194        let after_grow = s.state == "maintenance" && s.transition.as_ref().is_some_and(|t| t.then == After::GrowDone);
1195        if s.state != "online" && !after_grow {
1196            return Err(Refusal::new(
1197                409,
1198                "STORAGE_STATE_ILLEGAL",
1199                format!("storage {uuid} is {} — wait for online before resizing its filesystem", s.state),
1200            ));
1201        }
1202        for sv in self.attached_servers(uuid) {
1203            let state = self.servers.get(&sv).map(|x| x.state.clone()).unwrap_or_default();
1204            if state != "stopped" {
1205                return Err(Refusal::new(
1206                    409,
1207                    "SERVER_STATE_ILLEGAL",
1208                    format!("storage {uuid} is attached to server {sv}, which is {state}; the filesystem is resized only on a stopped server"),
1209                ));
1210            }
1211        }
1212        let backup = self.mint_resize_backup(uuid);
1213        if let Some(gib) = self.storages.get(uuid).map(|s| s.size_gib) {
1214            // Only reachable on a stopped server (refused above otherwise), so
1215            // the disk is never grown under a running machine.
1216            if let Err(why) = self.engine.resize_disk(uuid, gib) {
1217                eprintln!("mock-upcloud  storage {uuid}: disk resize refused: {why}");
1218            }
1219        }
1220        let now = self.clock.now_ms();
1221        let ms = self.timings.resize_ms;
1222        if let Some(s) = self.storages.get_mut(uuid) {
1223            s.state = "maintenance".into();
1224            s.transition = Some(Transition { to: "online".into(), at_ms: now + ms, then: After::Nothing });
1225        }
1226        Ok(backup)
1227    }
1228
1229    // ── reads ───────────────────────────────────────────────────────────────
1230
1231    pub fn server(&self, uuid: &str) -> Option<&Server> {
1232        self.servers.get(uuid)
1233    }
1234
1235    pub fn storage(&self, uuid: &str) -> Option<&Storage> {
1236        self.storages.get(uuid)
1237    }
1238
1239    pub fn was(&self, uuid: &str) -> Option<&'static str> {
1240        self.tombstones.get(uuid).copied()
1241    }
1242
1243    /// `GET /1.3/server`, filtered by `?label=key=value` (all must match).
1244    ///
1245    /// **Behaviour 5**: a revoked credential answers this with 200 and ZERO
1246    /// rows. Not 401. A caller that treats "no rows" as "nothing to clean up"
1247    /// deletes nothing and reports success, and a caller that treats it as
1248    /// "nothing exists" creates a second copy of everything.
1249    pub fn servers_matching(&self, labels: &[(String, String)]) -> Vec<&Server> {
1250        if self.faults.fires(Fault::RevokedCredential) {
1251            return vec![];
1252        }
1253        self.servers
1254            .values()
1255            .filter(|s| labels.iter().all(|(k, v)| s.label(k) == Some(v.as_str())))
1256            .collect()
1257    }
1258
1259    pub fn storages_matching(&self, labels: &[(String, String)], private_only: bool) -> Vec<&Storage> {
1260        if self.faults.fires(Fault::RevokedCredential) {
1261            return vec![];
1262        }
1263        self.storages
1264            .values()
1265            .filter(|s| !private_only || s.kind != StorageKind::Template)
1266            .filter(|s| {
1267                labels
1268                    .iter()
1269                    .all(|(k, v)| s.labels.iter().any(|l| l.key == *k && l.value == *v))
1270            })
1271            .collect()
1272    }
1273
1274    // ── writes ──────────────────────────────────────────────────────────────
1275
1276    pub fn create_storage(
1277        &mut self,
1278        title: &str,
1279        size_gib: u64,
1280        tier: &str,
1281        zone: &str,
1282        labels: Vec<Label>,
1283    ) -> Answer<String> {
1284        self.refuse_if_write_unavailable()?;
1285        if size_gib == 0 {
1286            return Err(Refusal::new(400, "STORAGE_INVALID_SIZE", "storage size must be at least 1 GiB"));
1287        }
1288        self.refuse_over_quota_for(tier, size_gib)?;
1289        let uuid = self.mint_uuid();
1290        let now = self.clock.now_ms();
1291        self.storages.insert(
1292            uuid.clone(),
1293            Storage {
1294                uuid: uuid.clone(),
1295                title: title.to_string(),
1296                size_gib,
1297                tier: tier.to_string(),
1298                zone: zone.to_string(),
1299                // Every storage is born in `maintenance`. It is `online` only
1300                // after `storage_create_ms`, and an attach before that is
1301                // refused — which is the poll loop's reason to exist.
1302                state: "maintenance".into(),
1303                kind: StorageKind::Normal,
1304                labels,
1305                origin: None,
1306                created_ms: now,
1307                import: None,
1308                transition: Some(Transition {
1309                    to: "online".into(),
1310                    at_ms: now + self.timings.storage_create_ms,
1311                    then: After::Created,
1312                }),
1313            },
1314        );
1315        Ok(uuid)
1316    }
1317
1318    /// **Behaviour 22.** A volume never shrinks. A growth is permanent and
1319    /// billed forever, so the refusal is by NAME and not a silent clamp: a
1320    /// clamp would let a caller believe it had shrunk something.
1321    pub fn modify_storage(&mut self, uuid: &str, size_gib: Option<u64>, title: Option<&str>) -> Answer<()> {
1322        self.refuse_if_write_unavailable()?;
1323        let Some(cur) = self.storages.get(uuid).map(|s| s.size_gib) else {
1324            return Err(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {uuid} not found")));
1325        };
1326        if let Some(n) = size_gib {
1327            // **Behaviour 54.** MEASURED 2026-09-14 on a throwaway volume:
1328            // smaller → `400 SIZE_INVALID` "The new size must be greater than
1329            // the old size." — and a SAME-size retry gets the same answer. The
1330            // mock said `STORAGE_INVALID_SIZE` and accepted the same size.
1331            if n <= cur {
1332                return Err(Refusal::new(
1333                    400,
1334                    "SIZE_INVALID",
1335                    format!("The new size must be greater than the old size. ({uuid} is {cur} GiB, {n} GiB was asked for)"),
1336                ));
1337            }
1338            // **Behaviour 56.** A grow under a RUNNING server is refused by
1339            // both of the docs' readings ("the server must be stopped" /
1340            // `STORAGE_ATTACHED` "must first be detached"); the stricter one —
1341            // any attachment at all — is `ResizeRequiresDetach`. The mock let
1342            // it through, and so did terraform through it: 5.44.1 sends this
1343            // PUT without stopping the server (BEHAVIOURS-LEDGER L101).
1344            let strict = self.faults.fires(Fault::ResizeRequiresDetach);
1345            for sv in self.attached_servers(uuid) {
1346                let state = self.servers.get(&sv).map(|x| x.state.clone()).unwrap_or_default();
1347                if strict || state != "stopped" {
1348                    return Err(Refusal::new(
1349                        409,
1350                        "STORAGE_ATTACHED",
1351                        format!("storage {uuid} is attached to server {sv} ({state}); it must first be detached (or the server stopped)"),
1352                    ));
1353                }
1354            }
1355            // **Behaviour 44.** The account's quota for the tier.
1356            self.refuse_over_quota(uuid, n - cur)?;
1357        }
1358        let now = self.clock.now_ms();
1359        let grow_ms = self.timings.storage_grow_ms;
1360        let s = self.storages.get_mut(uuid).expect("looked up above");
1361        if let Some(n) = size_gib {
1362            s.size_gib = n;
1363            // **Behaviour 55.** `maintenance` for 37 s, then `online` —
1364            // MEASURED on a detached 1→2 GiB grow. It was instant here.
1365            s.state = "maintenance".into();
1366            s.transition = Some(Transition { to: "online".into(), at_ms: now + grow_ms, then: After::GrowDone });
1367            // The record is the truth; the disk follows it now if no running
1368            // machine has it open, and at the next power-on otherwise.
1369            if let Err(why) = self.engine.resize_disk(uuid, n) {
1370                eprintln!("mock-upcloud  storage {uuid}: disk resize deferred: {why}");
1371            }
1372        }
1373        if let Some(t) = title {
1374            if let Some(s) = self.storages.get_mut(uuid) {
1375                s.title = t.to_string();
1376            }
1377        }
1378        // ★ **A device carries a COPY of its storage's size and title, and the
1379        // copy must move with the original.**
1380        //
1381        // MEASURED 2026-09-21: `terraform apply` grew holger-web's system disk
1382        // from 20 to 25 GB, the PUT succeeded, and the provider then refused its
1383        // own apply —
1384        //
1385        //   Provider produced inconsistent result after apply … .template[0].size:
1386        //   was cty.NumberIntVal(25), but now cty.NumberIntVal(20)
1387        //
1388        // — because it reads a machine's template size off the SERVER's
1389        // `storage_devices`, where this mock was still reporting the size the
1390        // device had when it was attached. The account cannot answer that way:
1391        // there is one volume and one size, and the device row is a view of it.
1392        // A grow that no read reports is a write that reports its own success
1393        // and did not happen, which is the class of defect this crate exists to
1394        // reproduce — not to have.
1395        let (size, title) = {
1396            let s = self.storages.get(uuid).expect("just modified");
1397            (s.size_gib, s.title.clone())
1398        };
1399        for sv in self.servers.values_mut() {
1400            for d in sv.devices.iter_mut().filter(|d| d.storage == uuid) {
1401                d.storage_size = size;
1402                d.storage_title = title.clone();
1403            }
1404        }
1405        Ok(())
1406    }
1407
1408    pub fn delete_storage(&mut self, uuid: &str) -> Answer<()> {
1409        self.refuse_if_write_unavailable()?;
1410        let attached: Vec<String> = self
1411            .servers
1412            .values()
1413            .filter(|s| s.devices.iter().any(|d| d.storage == uuid))
1414            .map(|s| s.uuid.clone())
1415            .collect();
1416        if !attached.is_empty() {
1417            return Err(Refusal::new(
1418                409,
1419                "STORAGE_DEVICE_ATTACHED",
1420                format!("storage {uuid} is attached to {}", attached.join(", ")),
1421            ));
1422        }
1423        // **Behaviour 59.** A storage mid-operation, or the SOURCE of a clone
1424        // that is still being made, is refused `409 STORAGE_STATE_ILLEGAL` —
1425        // REPORTED (monetize-impl `tests.rs`; gunnar `api.rs`: "or a clone's
1426        // source while the clone exists"). The mock deleted both.
1427        if let Some(s) = self.storages.get(uuid) {
1428            if s.state == "maintenance" || s.state == "syncing" {
1429                return Err(Refusal::new(
1430                    409,
1431                    "STORAGE_STATE_ILLEGAL",
1432                    format!("storage {uuid} is {}; it cannot be deleted now", s.state),
1433                ));
1434            }
1435        }
1436        if let Some(c) = self.storages.values().find(|c| {
1437            c.kind == StorageKind::Normal && c.origin.as_deref() == Some(uuid) && c.transition.is_some()
1438        }) {
1439            return Err(Refusal::new(
1440                409,
1441                "STORAGE_STATE_ILLEGAL",
1442                format!("storage {uuid} is the source of clone {}, which is still {}", c.uuid, c.state),
1443            ));
1444        }
1445        let now = self.clock.now_ms();
1446        let ms = self.timings.storage_delete_ms;
1447        let Some(s) = self.storages.get_mut(uuid) else {
1448            return Err(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {uuid} not found")));
1449        };
1450        s.state = "maintenance".into();
1451        s.transition = Some(Transition { to: "gone".into(), at_ms: now + ms, then: After::Vanish });
1452        Ok(())
1453    }
1454
1455    pub fn attached_servers(&self, storage: &str) -> Vec<String> {
1456        self.servers
1457            .values()
1458            .filter(|s| s.devices.iter().any(|d| d.storage == storage))
1459            .map(|s| s.uuid.clone())
1460            .collect()
1461    }
1462
1463    #[allow(clippy::too_many_arguments)]
1464    pub fn create_server(
1465        &mut self,
1466        title: &str,
1467        hostname: &str,
1468        plan: &str,
1469        zone: &str,
1470        labels: Vec<Label>,
1471        boot_disk_title: &str,
1472        boot_disk_gib: u64,
1473    ) -> Answer<String> {
1474        self.refuse_if_write_unavailable()?;
1475        // **Behaviour 37 — a sold-out zone refuses the CREATE, not only the
1476        // poweron.** Scaleway's `fr-par-1` (verified against Scaleway, not UpCloud;
1477        // kept as a generic cloud-provider fault) answered `412 out_of_stock` to `poweron` from
1478        // 2026-09-09 and had not cleared by 2026-09-20, and what is out of
1479        // stock there is CAPACITY FOR A PLAN IN A ZONE — which a create needs
1480        // exactly as much as a start does, and asks for first. The mock served
1481        // only the poweron door, so a caller that meets the outage at the
1482        // create could never have been driven against it: it would have got a
1483        // `201`, polled a server the provider never had, and reported the
1484        // timeout rather than the refusal.
1485        //
1486        // The SAME sticky [`Fault::OutOfStock`], deliberately, and not a second
1487        // variant. One zone is out of stock or it is not; a mock that could be
1488        // sold out at `poweron` and in stock at `create` would model a provider
1489        // nobody has measured, and it would let a caller pass by discovering
1490        // the shortage at whichever door it happened to knock on. If the two
1491        // ever ARE measured apart, that measurement is what earns the second
1492        // variant.
1493        //
1494        // Nothing is minted before this: the refusal leaves the account
1495        // untouched, so a caller that retries does not find half a server.
1496        if self.faults.fires(Fault::OutOfStock) {
1497            return Err(Refusal::new(
1498                412,
1499                "out_of_stock",
1500                "the zone has no capacity for this plan at the moment",
1501            ));
1502        }
1503        self.refuse_over_quota_for("maxiops", boot_disk_gib)?;
1504        let uuid = self.mint_uuid();
1505        let now = self.clock.now_ms();
1506        let disk_uuid = self.mint_uuid();
1507        self.storages.insert(
1508            disk_uuid.clone(),
1509            Storage {
1510                uuid: disk_uuid.clone(),
1511                title: boot_disk_title.to_string(),
1512                size_gib: boot_disk_gib,
1513                tier: "maxiops".into(),
1514                zone: zone.to_string(),
1515                state: "maintenance".into(),
1516                kind: StorageKind::Normal,
1517                labels: labels.clone(),
1518                origin: None,
1519                created_ms: now,
1520                import: None,
1521                transition: Some(Transition {
1522                    to: "online".into(),
1523                    at_ms: now + self.timings.storage_create_ms,
1524                    then: After::Nothing,
1525                }),
1526            },
1527        );
1528        // Behaviour 6: 98–105 s, drawn from the run's seed.
1529        let mut r = SplitMix64::derive(self.seed, &format!("create/{uuid}"));
1530        let ms = r.range(self.timings.server_create_lo_ms, self.timings.server_create_hi_ms);
1531        // Five figures, like `se-sto1.vnc.upcloud.com:60031`. It was 5900+ here
1532        // and 60000+ in `start_server`, which is the kind of disagreement that
1533        // lets a client key off the WRONG shape and pass.
1534        let port = 60_000 + (r.below(1000) as u16);
1535        // Decided ONCE, here, and never again: an appliance boots with a
1536        // userland that cannot say what the RTC holds, and a front boots with
1537        // one that can. Which of the two this server is, is a fact about the
1538        // image it was made from, not a coin flipped on every read.
1539        let rtc_local = self.faults.fires(Fault::GuestReadsRtcAsLocalTime);
1540        // Decided once, like the RTC interpretation and for the same reason: it
1541        // is a fact about the image this server was made from, not a coin
1542        // flipped per packet.
1543        let ignores_121 = self.faults.fires(Fault::GuestIgnoresDhcpOption121);
1544        let hotplug = !self.faults.fires(Fault::GuestKernelLacksHotplug);
1545        let public_ip = self.addresses.take_public();
1546        let utility_ip = self.addresses.take_utility();
1547        self.servers.insert(
1548            uuid.clone(),
1549            Server {
1550                uuid: uuid.clone(),
1551                title: title.to_string(),
1552                hostname: hostname.to_string(),
1553                plan: plan.to_string(),
1554                zone: zone.to_string(),
1555                state: "maintenance".into(),
1556                labels,
1557                devices: vec![Device {
1558                    address: "virtio:0".into(),
1559                    storage: disk_uuid,
1560                    storage_title: boot_disk_title.to_string(),
1561                    storage_size: boot_disk_gib,
1562                    kind: "disk",
1563                    boot_disk: true,
1564                }],
1565                boot_order: BootOrder::Disk,
1566                remote_access_enabled: false,
1567                remote_access_password: String::new(),
1568                vnc_port: port,
1569                reported_vnc_port: port,
1570                // `<zone>.vnc.upcloud.com` at the provider; RFC 2606 here, so
1571                // a mock never reports a host that resolves to anything real.
1572                // A real guest behind the server replaces it with its own
1573                // loopback VNC at boot.
1574                vnc_host: format!("{zone}.vnc.mock.invalid"),
1575                reported_vnc_host: format!("{zone}.vnc.mock.invalid"),
1576                public_ip,
1577                utility_ip,
1578                guest: Guest::Off,
1579                dhcp_client: if ignores_121 {
1580                    crate::net::DhcpClient::IgnoresOption121
1581                } else {
1582                    crate::net::DhcpClient::ReadsOption121
1583                },
1584                ssh_host_key: String::new(),
1585                rtc: if rtc_local {
1586                    crate::guest_clock::RtcInterpretation::LocalTime
1587                } else {
1588                    crate::guest_clock::RtcInterpretation::Utc
1589                },
1590                created_ms: now,
1591                transition: Some(Transition {
1592                    to: "started".into(),
1593                    at_ms: now + ms,
1594                    then: After::GuestBoots,
1595                }),
1596                ifaces: Iface::default_pair(),
1597                rules: vec![],
1598                firewall_on: false,
1599                metadata: true,
1600                timezone: "UTC".into(),
1601                hotplug,
1602                console_settles_at: None,
1603            },
1604        );
1605        Ok(uuid)
1606    }
1607
1608    /// ★ **The terraform create's second half.** `create_server` mints the
1609    /// machine every caller in this crate shares; this says the things only the
1610    /// terraform door asks for, in ONE place, so the create path is not forked
1611    /// into two machines that drift.
1612    ///
1613    /// It is deliberately separate from `modify_server`: that one is the
1614    /// `PUT /1.3/server/{uuid}` a caller makes later and carries the
1615    /// stop/start behaviours. This is part of the create and makes no
1616    /// transition at all.
1617    pub fn configure_server(
1618        &mut self,
1619        uuid: &str,
1620        ifaces: Vec<Iface>,
1621        boot_order: Option<BootOrder>,
1622        firewall_on: bool,
1623        metadata: bool,
1624        timezone: &str,
1625    ) -> Answer<()> {
1626        let s = self
1627            .servers
1628            .get_mut(uuid)
1629            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")))?;
1630        if !ifaces.is_empty() {
1631            s.ifaces = ifaces;
1632        }
1633        if let Some(b) = boot_order {
1634            s.boot_order = b;
1635        }
1636        s.firewall_on = firewall_on;
1637        s.metadata = metadata;
1638        s.timezone = timezone.to_string();
1639        Ok(())
1640    }
1641
1642    /// The rule set of a machine, or the refusal the firewall door owes. The
1643    /// 403s (behaviours 1 and 35) stay in [`crate::http`] where they are the
1644    /// ANSWER rather than the state.
1645    pub fn rules(&self, uuid: &str) -> Option<&[Rule]> {
1646        self.servers.get(uuid).map(|s| s.rules.as_slice())
1647    }
1648
1649    /// Write a machine's whole rule set. UpCloud's firewall is a SET and not a
1650    /// list of independently addressable objects — a write replaces it — which
1651    /// is why `upcloud_firewall_rules` is one terraform resource per machine
1652    /// and not one per rule, and why this takes the whole vector.
1653    pub fn set_rules(&mut self, uuid: &str, rules: Vec<Rule>) -> Answer<()> {
1654        self.refuse_if_write_unavailable()?;
1655        let s = self
1656            .servers
1657            .get_mut(uuid)
1658            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")))?;
1659        s.rules = rules;
1660        // The positions the API hands back are its own, renumbered from 1 in
1661        // the order the set was written. A caller that sent none gets them
1662        // anyway, and a caller that sent its own does not get to keep gaps.
1663        for (i, r) in s.rules.iter_mut().enumerate() {
1664            r.position = (i + 1).to_string();
1665        }
1666        Ok(())
1667    }
1668
1669    /// **Behaviour 11.** A server must be STOPPED before it can be deleted with
1670    /// its storages. A started one is refused, not queued.
1671    ///
1672    /// **Behaviour 7.** The delete itself sits in `maintenance` for 60 s plus
1673    /// 65 s per attached member volume — an appliance carrying four of them is
1674    /// over five minutes, which is what was measured and what every caller's
1675    /// timeout was too short for.
1676    pub fn delete_server(&mut self, uuid: &str, with_storages: bool) -> Answer<()> {
1677        self.refuse_if_write_unavailable()?;
1678        let Some(s) = self.servers.get(uuid) else {
1679            return Err(Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")));
1680        };
1681        if s.state != "stopped" {
1682            return Err(Refusal::new(
1683                409,
1684                "SERVER_STATE_ILLEGAL",
1685                format!("server {uuid} is {} — stop it before deleting it", s.state),
1686            ));
1687        }
1688        let members = s.devices.iter().filter(|d| !d.boot_disk && d.kind == "disk").count() as u64;
1689        let ms = self.timings.server_delete_ms + members * self.timings.server_delete_per_volume_ms;
1690        let now = self.clock.now_ms();
1691        // The machine goes now; its disks go when their storages do.
1692        self.engine.forget_server(uuid);
1693        let s = self.servers.get_mut(uuid).expect("just had it");
1694        s.state = "maintenance".into();
1695        if !with_storages {
1696            s.devices.clear();
1697        }
1698        s.transition = Some(Transition { to: "gone".into(), at_ms: now + ms, then: After::Vanish });
1699        Ok(())
1700    }
1701
1702    /// **Behaviour 3.** Out of stock, at poweron, with a `412`. It was measured
1703    /// on `fr-par-1` from 2026-09-09 and it did not clear for days, so the fault
1704    /// is sticky: a retry loop must not be able to outwait it.
1705    pub fn start_server(&mut self, uuid: &str) -> Answer<()> {
1706        self.refuse_if_write_unavailable()?;
1707        if self.faults.fires(Fault::OutOfStock) {
1708            return Err(Refusal::new(
1709                412,
1710                "out_of_stock",
1711                "the zone has no capacity for this plan at the moment",
1712            ));
1713        }
1714        let now = self.clock.now_ms();
1715        // An INSTALLER start (CD first, a medium in the tray) reads its brief
1716        // `started` at 3.6 s (receipt 2026-09-21); a disk boot at ~10 s.
1717        let installer = self.servers.get(uuid).is_some_and(|s| {
1718            s.boot_order.cdrom_first() && s.devices.iter().any(|d| d.kind == "cdrom" && !d.storage.is_empty())
1719        });
1720        let ms = if installer { self.timings.installer_started_ms } else { self.timings.start_ms };
1721        let stale = self.faults.fires(Fault::StaleVncPort);
1722        let mut r = SplitMix64::derive(self.seed, &format!("vnc/{uuid}/{now}"));
1723        // MEASURED: `se-sto1.vnc.upcloud.com:60031`. The port is five figures
1724        // and the host is the ZONE's console, not the server's own address, so
1725        // both halves are re-provisioned and neither is derivable from the
1726        // server.
1727        let fresh_port = 60_000 + (r.below(1000) as u16);
1728        let fresh_host = format!("{}.vnc.mock.invalid", self.zone);
1729        let Some(s) = self.servers.get_mut(uuid) else {
1730            return Err(Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")));
1731        };
1732        if s.state == "started" {
1733            return Err(Refusal::new(409, "SERVER_STATE_ILLEGAL", "server is already started"));
1734        }
1735        // A start while an operation is in flight (a create, a stop, an
1736        // install pass, a delete) is refused, as every other illegal state is
1737        // (docs). It used to be accepted and to OVERWRITE the pending
1738        // transition — which is how a plan change lost its Resize Backup.
1739        if s.state == "maintenance" {
1740            return Err(Refusal::new(
1741                409,
1742                "SERVER_STATE_ILLEGAL",
1743                format!("server {uuid} is in maintenance; wait for it to settle before starting it"),
1744            ));
1745        }
1746        // Behaviour 14: the hypervisor binds a NEW port on every start, and the
1747        // API keeps reporting the old one until `remote_access_enabled` is
1748        // toggled. The divergence is the defect; the toggle is the cure, and
1749        // both are here so the cure can be tested.
1750        s.vnc_port = fresh_port;
1751        s.vnc_host = fresh_host.clone();
1752        if !stale {
1753            s.reported_vnc_port = fresh_port;
1754            s.reported_vnc_host = fresh_host;
1755        }
1756        s.state = "maintenance".into();
1757        s.transition = Some(Transition { to: "started".into(), at_ms: now + ms, then: After::GuestBoots });
1758        Ok(())
1759    }
1760
1761    pub fn stop_server(&mut self, uuid: &str, hard: bool) -> Answer<()> {
1762        self.refuse_if_write_unavailable()?;
1763        let now = self.clock.now_ms();
1764        let ms = if hard { self.timings.stop_hard_ms } else { self.timings.stop_soft_ms };
1765        let Some(s) = self.servers.get_mut(uuid) else {
1766            return Err(Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")));
1767        };
1768        if s.state == "stopped" {
1769            return Err(Refusal::new(409, "SERVER_STATE_ILLEGAL", "server is already stopped"));
1770        }
1771        // A wedged installer answers no ACPI event at all, so a SOFT stop of a
1772        // looping guest never completes — which is why the re-image uses a hard
1773        // stop once the media are on.
1774        if !hard && matches!(s.guest, Guest::Looping { .. } | Guest::Panicked) {
1775            return Err(Refusal::new(
1776                409,
1777                "SERVER_STATE_ILLEGAL",
1778                "the guest did not answer the shutdown request within the timeout",
1779            ));
1780        }
1781        s.state = "maintenance".into();
1782        s.guest = Guest::Off;
1783        s.transition = Some(Transition { to: "stopped".into(), at_ms: now + ms, then: After::Nothing });
1784        // Soft: the ACPI button, now. Hard: the kill, now. Either way the
1785        // transition to `stopped` above kills whatever is left.
1786        self.engine.power_off(uuid, hard);
1787        Ok(())
1788    }
1789
1790    /// `PUT /1.3/server/{uuid}` — the plan change, the boot order, the labels
1791    /// and the `remote_access_enabled` toggle, which is the whole of what this
1792    /// estate uses it for.
1793    pub fn modify_server(
1794        &mut self,
1795        uuid: &str,
1796        plan: Option<&str>,
1797        boot_order: Option<BootOrder>,
1798        labels: Option<Vec<Label>>,
1799        remote_access: Option<bool>,
1800        remote_access_password: Option<&str>,
1801    ) -> Answer<()> {
1802        self.refuse_if_write_unavailable()?;
1803        let now = self.clock.now_ms();
1804        let settle_ms = self.timings.vnc_settle_ms;
1805        let Some(s) = self.servers.get_mut(uuid) else {
1806            return Err(Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")));
1807        };
1808        // **Behaviour 51.** The console toggle answers 409 while the server is
1809        // in `maintenance` — MEASURED (memory: upcloud-vnc-port-toggle, "409
1810        // while state=maintenance (retry)"). Checked before anything changes.
1811        if remote_access.is_some() && s.state == "maintenance" {
1812            return Err(Refusal::new(
1813                409,
1814                "SERVER_STATE_ILLEGAL",
1815                format!("server {uuid} is in maintenance; remote access cannot be changed now"),
1816            ));
1817        }
1818        if let Some(bo) = boot_order {
1819            s.boot_order = bo;
1820        }
1821        if let Some(l) = labels {
1822            s.labels = l;
1823        }
1824        if let Some(on) = remote_access {
1825            // **The cure for behaviour 14.** Turning remote access off and on
1826            // again is what makes the API report the port the hypervisor is
1827            // really on. Nothing else does — not a stop/start, not a read.
1828            s.remote_access_enabled = on;
1829            if !on {
1830                // **Behaviour 50.** The "no" starts moving the console, and the
1831                // move takes ~2 s. The PUT has already answered by then.
1832                s.console_settles_at = Some(now + settle_ms);
1833            } else if s.console_settles_at.is_some_and(|t| now < t) {
1834                // A "yes" inside the window: the OLD endpoint comes back, and
1835                // stays until the next toggle — MEASURED, the reason every
1836                // working tool pauses between the two PUTs.
1837            } else {
1838                // BOTH halves. A cure that reconciled only the port would leave
1839                // a client dialling the right port at the wrong host.
1840                s.reported_vnc_port = s.vnc_port;
1841                s.reported_vnc_host = s.vnc_host.clone();
1842                s.console_settles_at = None;
1843            }
1844        }
1845        if let Some(p) = remote_access_password {
1846            s.remote_access_password = p.to_string();
1847        }
1848        // ★ **A PUT that names the SAME plan is not a plan change.**
1849        //
1850        // MEASURED 2026-09-21 with `--log`: `UpCloudLtd/upcloud` 5.44.1 grows a
1851        // system disk by sending the WHOLE server object, plan included and
1852        // unchanged. This mock read "a plan is present" as "the plan changed",
1853        // so an unrelated apply —
1854        //
1855        //   POST /server/{u}/stop        → maintenance
1856        //   GET  /server/{u}             → stopped        (the provider waited)
1857        //   PUT  /server/{u}             → maintenance    ← for a no-op
1858        //   PUT  /storage/{disk}         (the growth)
1859        //   POST /storage/{disk}/resize  → 409 SERVER_STATE_ILLEGAL
1860        //
1861        // — put the machine back into `maintenance` for a plan it already had,
1862        // refused the filesystem resize that followed, and minted a spurious
1863        // `Resize Backup` for a resize that never happened. Behaviour 9 is real
1864        // and stays; what was wrong was firing it for a change that is not one.
1865        // A mock stricter than the provider invents verdicts, and this one
1866        // invented a broken growth path AND the very leak it was written to
1867        // reproduce.
1868        let plan = plan.filter(|p| *p != s.plan);
1869        if let Some(p) = plan {
1870            if s.state != "stopped" {
1871                return Err(Refusal::new(
1872                    409,
1873                    "SERVER_STATE_ILLEGAL",
1874                    format!("the plan can only be changed while the server is stopped; it is {}", s.state),
1875                ));
1876            }
1877            let origin = s.boot_disk().map(|d| d.storage.clone()).unwrap_or_default();
1878            s.plan = p.to_string();
1879            // **Behaviour 48.** The plan change is done when the PUT answers:
1880            // the server stays `stopped`. MEASURED 2026-09-21 through the real
1881            // provider: 5.44.1 sends `start` straight after this PUT without
1882            // waiting, and it is applied against the live account, so the live
1883            // PUT leaves nothing to wait for. The mock used to hold the server
1884            // in `maintenance` for `resize_ms`; the provider's `start` then
1885            // overwrote that transition and the Resize Backup it carried was
1886            // never minted. Behaviour 9 (a plan change mints one) stays, but is
1887            // UNCONFIRMED: the measured 44 GB came from a FILESYSTEM resize.
1888            let _ = self.mint_resize_backup(&origin);
1889        }
1890        Ok(())
1891    }
1892
1893    /// A server's `hostname` and `title`, changed in place.
1894    pub fn rename_server(&mut self, uuid: &str, hostname: Option<&str>, title: Option<&str>) {
1895        if let Some(s) = self.servers.get_mut(uuid) {
1896            if let Some(h) = hostname {
1897                s.hostname = h.to_string();
1898            }
1899            if let Some(t) = title {
1900                s.title = t.to_string();
1901            }
1902        }
1903    }
1904
1905    pub fn attach(&mut self, server: &str, storage: &str, kind: &str) -> Answer<String> {
1906        self.attach_at(server, storage, kind, None)
1907    }
1908
1909    /// **An attach, at the address the caller asked for (behaviour 58).**
1910    /// `virtio:3` is honoured; `virtio` (or nothing) lets UpCloud pick the
1911    /// first FREE slot — REPORTED (monetize-impl `grow.rs`: `address:"virtio"`
1912    /// lets UpCloud pick). The mock used to ignore the request and take
1913    /// `virtio:<count>`, which collides after a middle detach and never puts a
1914    /// disk where a caller asked. Where it lands decides the guest's name for
1915    /// it ([`Estate::guest_disk_names`]), and that decides the installer's
1916    /// disk election.
1917    pub fn attach_at(&mut self, server: &str, storage: &str, kind: &str, want: Option<&str>) -> Answer<String> {
1918        self.refuse_if_write_unavailable()?;
1919        let st = self
1920            .storages
1921            .get(storage)
1922            .ok_or_else(|| Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {storage} not found")))?;
1923        if st.state != "online" {
1924            return Err(Refusal::new(
1925                409,
1926                "STORAGE_STATE_ILLEGAL",
1927                format!("storage {storage} is {} — wait for online", st.state),
1928            ));
1929        }
1930        let (title, size) = (st.title.clone(), st.size_gib);
1931        // **Behaviour 57.** A storage another device already holds is refused
1932        // `409 STORAGE_ATTACHED` — REPORTED (monetize-impl treats it as a
1933        // retry; private-holger-ops `front.rs`: "UpCloud refuses to attach a
1934        // storage another server holds"). The mock attached it twice. A
1935        // cdrom with an empty tray holds nothing and does not count.
1936        if let Some(holder) = self
1937            .servers
1938            .values()
1939            .find(|s| s.devices.iter().any(|d| d.storage == storage && !storage.is_empty()))
1940        {
1941            return Err(Refusal::new(
1942                409,
1943                "STORAGE_ATTACHED",
1944                format!("storage {storage} is already attached to server {}", holder.uuid),
1945            ));
1946        }
1947        let s = self
1948            .servers
1949            .get_mut(server)
1950            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {server} not found")))?;
1951        // **An attach is not the mirror of a detach, and the asymmetry is the
1952        // provider's.** A VIRTIO disk hot-plugs onto a RUNNING server and the
1953        // call succeeds — that is how a growth adds a member volume without a
1954        // reboot. An IDE cdrom does not, and is refused with the same
1955        // `IDE_HOTPLUG_UNSUPPORTED` a detach gets.
1956        //
1957        // This was wrong here first: the mock refused BOTH on a started server,
1958        // and the storm's very first run reported 86.67% of a hundred thousand
1959        // purchases failing with `409 SERVER_STATE_ILLEGAL` on a perfectly legal
1960        // hot-plug. A mock that is stricter than the provider manufactures
1961        // defects, which is worse than one that is laxer — a lax mock misses a
1962        // bug, a strict one invents eighty-six thousand.
1963        if s.state != "stopped" && kind == "cdrom" {
1964            return Err(Refusal::new(
1965                409,
1966                "IDE_HOTPLUG_UNSUPPORTED",
1967                format!("an ide cdrom cannot be attached while {server} is {}", s.state),
1968            ));
1969        }
1970        // **Behaviour 60.** …but only if the GUEST can take it. A kernel with
1971        // no PCI hot-plug never acks, and the call answers `511
1972        // HOTPLUG_FAILED` (MEASURED 2026-09-14, tunnr 6.12.104).
1973        if s.state == "started" && kind != "cdrom" && !s.hotplug {
1974            return Err(hotplug_failed(server));
1975        }
1976        let address = match (kind, want) {
1977            ("cdrom", Some(a)) if a.starts_with("ide:") => a.to_string(),
1978            ("cdrom", _) => "ide:0:0".to_string(),
1979            (_, Some(a)) if a.starts_with("virtio:") => a.to_string(),
1980            _ => {
1981                let used: Vec<u32> = s
1982                    .devices
1983                    .iter()
1984                    .filter_map(|d| d.address.strip_prefix("virtio:").and_then(|n| n.parse().ok()))
1985                    .collect();
1986                let n = (0..).find(|n| !used.contains(n)).expect("an unbounded range has a free slot");
1987                format!("virtio:{n}")
1988            }
1989        };
1990        if s.devices.iter().any(|d| d.address == address) {
1991            return Err(Refusal::new(409, "STORAGE_DEVICE_ADDRESS_IN_USE", format!("{address} is taken")));
1992        }
1993        s.devices.push(Device {
1994            address: address.clone(),
1995            storage: storage.to_string(),
1996            storage_title: title,
1997            storage_size: size,
1998            kind: if kind == "cdrom" { "cdrom" } else { "disk" },
1999            boot_disk: false,
2000        });
2001        Ok(address)
2002    }
2003
2004    /// **The names the GUEST gives the disks (behaviour 58).** virtio-blk
2005    /// devices are named in PCI slot order and the names are CONTIGUOUS:
2006    /// `virtio:0, virtio:2, virtio:5` are `vda, vdb, vdc`. MEASURED as a rule
2007    /// the ladder depends on (gunnar `machine.rs`, holger HOLGER-PLAN: the ISO's
2008    /// virtio copy is attached AFTER the data volume "so that it becomes vdc,
2009    /// not vdb"), and it is what `korp-installer`'s disk election reads. The
2010    /// IDE cdrom is not a Linux block device here: the kernel has no ATA.
2011    pub fn guest_disk_names(&self, server: &str) -> Vec<(String, String)> {
2012        let Some(s) = self.servers.get(server) else { return vec![] };
2013        let mut slots: Vec<(u32, String)> = s
2014            .devices
2015            .iter()
2016            .filter_map(|d| d.address.strip_prefix("virtio:").and_then(|n| n.parse().ok()).map(|n| (n, d.address.clone())))
2017            .collect();
2018        slots.sort();
2019        slots
2020            .into_iter()
2021            .enumerate()
2022            .map(|(i, (_, a))| (a, format!("vd{}", (b'a' + i as u8) as char)))
2023            .collect()
2024    }
2025
2026    /// **A device named in the CREATE body** (`storage_devices` entries with
2027    /// `"action": "attach"`). The server is in `maintenance` and has never run,
2028    /// so the cdrom's hot-plug refusal in [`Estate::attach`] does not apply:
2029    /// this is how the provider takes an installer medium at create. The storage
2030    /// must exist and be `online`, exactly as for a later attach.
2031    pub fn attach_at_create(&mut self, server: &str, storage: &str, kind: &str, want: Option<&str>) -> Answer<String> {
2032        let state = self.servers.get(server).map(|s| s.state.clone());
2033        if let Some(s) = self.servers.get_mut(server) {
2034            s.state = "stopped".into();
2035        }
2036        let r = self.attach_at(server, storage, kind, want);
2037        if let (Some(st), Some(s)) = (state, self.servers.get_mut(server)) {
2038            s.state = st;
2039        }
2040        r
2041    }
2042
2043    /// **Behaviour 16, the half that refuses.** A detach names an ADDRESS, and
2044    /// on a STARTED server an ide address is refused `IDE_HOTPLUG_UNSUPPORTED`,
2045    /// and a virtio one is the guest's to allow (behaviour 60: a kernel without
2046    /// PCI hot-plug answers `511 HOTPLUG_FAILED`). The only thing that takes a medium off a running box
2047    /// is [`Estate::eject`].
2048    pub fn detach(&mut self, server: &str, address: &str) -> Answer<()> {
2049        self.refuse_if_write_unavailable()?;
2050        // Read before the server is borrowed mutably, as `start_server` reads
2051        // the stale-VNC decision: `fires` wants `&self`.
2052        let lies = self.faults.fires(Fault::DetachSaysSuccessButStaysAttached);
2053        let s = self
2054            .servers
2055            .get_mut(server)
2056            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {server} not found")))?;
2057        // **A detach names an ADDRESS, and only an address.** MEASURED against
2058        // the live account on 2026-09-07 and written down in
2059        // `gunnar/deploy/upcloud/src/api.rs`: `{"storage_device": {"address":
2060        // "virtio:1"}}`. A body that carries a storage uuid instead is missing
2061        // the one attribute the endpoint takes.
2062        //
2063        // The refusal is explicit because a silent `404 no device at ""` reads
2064        // like "that disk is already gone" — which is exactly the wrong
2065        // conclusion for a caller trying to stop paying for it. (The status and
2066        // code here are this mock's reading of UpCloud's 1.3 error table for a
2067        // missing attribute; what is MEASURED is that a uuid-only detach does
2068        // not work, not which of its error codes comes back.)
2069        if address.is_empty() {
2070            return Err(Refusal::new(
2071                400,
2072                "MISSING_ATTRIBUTE",
2073                "storage_device.address is required: a detach names an address (virtio:1, ide:0:0), never a storage uuid",
2074            ));
2075        }
2076        let Some(i) = s.devices.iter().position(|d| d.address == address) else {
2077            return Err(Refusal::new(404, "STORAGE_DEVICE_NOT_FOUND", format!("no device at {address}")));
2078        };
2079        // **Behaviour 60 (was 16, and 16 had the wrong code).** On a STARTED
2080        // server an IDE detach is `409 IDE_HOTPLUG_UNSUPPORTED`, and a virtio
2081        // detach is the GUEST's to allow: a hot-plug kernel acks the eject
2082        // and it succeeds; one without PCI hot-plug never acks and the call is
2083        // `511 HOTPLUG_FAILED` — MEASURED 2026-09-14 on the live appliance.
2084        // This mock answered `409 VIRTIO_HOTPLUG_UNSUPPORTED`, a code nobody
2085        // measured, for every guest; monetize-impl catches only that 409, so
2086        // a real 511 skips its stop/detach/start fallback (ledger X4).
2087        if s.state == "started" {
2088            if address.starts_with("ide") {
2089                return Err(Refusal::new(
2090                    409,
2091                    "IDE_HOTPLUG_UNSUPPORTED",
2092                    format!("{address} cannot be detached while the server is running"),
2093                ));
2094            }
2095            if !s.hotplug {
2096                return Err(hotplug_failed(server));
2097            }
2098        }
2099        // **Behaviour 38 — the write that reports its own success and did not
2100        // happen.** Every refusal above still holds: the address must exist and
2101        // the server must not be running, because this is not "detach is
2102        // broken", it is "the detach that WOULD have worked answered 200 and
2103        // left the device on". A caller that reads the status and not the
2104        // server afterwards cannot tell this from the real thing, which is the
2105        // entire point — and until this path existed the mock had no way to be
2106        // wrong here at all, so every green a sweep printed against it was a
2107        // green about an instrument that could only say yes truthfully.
2108        if lies {
2109            return Ok(());
2110        }
2111        s.devices.remove(i);
2112        Ok(())
2113    }
2114
2115    /// **Behaviour 16, the half that works.** `cdrom/eject` takes the MEDIUM out
2116    /// and leaves the device. MEASURED `200` on a STARTED server on 2026-09-14,
2117    /// and it ended an install loop: the next boot found no medium and fell
2118    /// through to the disk. It is the only never-loop primitive there is.
2119    pub fn eject(&mut self, server: &str) -> Answer<()> {
2120        self.refuse_if_write_unavailable()?;
2121        let s = self
2122            .servers
2123            .get_mut(server)
2124            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {server} not found")))?;
2125        let Some(d) = s.devices.iter_mut().find(|d| d.kind == "cdrom") else {
2126            return Err(Refusal::new(404, "STORAGE_DEVICE_NOT_FOUND", "no cdrom device"));
2127        };
2128        d.storage = String::new();
2129        d.storage_title = String::new();
2130        d.storage_size = 0;
2131        if matches!(s.guest, Guest::Looping { .. }) {
2132            s.guest = Guest::Installed;
2133        }
2134        // The tray empties on the running machine too; the next power-on has
2135        // no medium and so no CD in its boot order.
2136        self.engine.eject(server);
2137        Ok(())
2138    }
2139
2140    /// **`cdrom/load` — the other half of eject.** Puts a storage into the
2141    /// existing cdrom device of an existing server, so a re-image with a
2142    /// DIFFERENT medium needs no detach and no re-create. With the CD first in
2143    /// the boot order, the next hypervisor start boots it (L50).
2144    ///
2145    /// NOT MEASURED, and not in the ledger: the codes are UpCloud 1.3's
2146    /// documented ones for this endpoint. What is modelled: it works on a
2147    /// started server like eject does (L52); a tray that already holds a
2148    /// medium is refused (`CDROM_DEVICE_IN_USE`, eject first); a server with no
2149    /// cdrom device is refused (`CDROM_DEVICE_NOT_FOUND`); the storage must
2150    /// exist and be `online`. The provider also requires the storage to be of
2151    /// type `cdrom`; the mock does not, because its uploaded media are `normal`
2152    /// storages that the ladder attaches as `type: cdrom`, which is MEASURED to
2153    /// work (L49).
2154    pub fn load_cdrom(&mut self, server: &str, storage: &str) -> Answer<()> {
2155        self.refuse_if_write_unavailable()?;
2156        if storage.is_empty() {
2157            return Err(Refusal::new(400, "STORAGE_MISSING", "storage_device.storage is required: name the storage to load"));
2158        }
2159        if !self.servers.contains_key(server) {
2160            return Err(Refusal::new(404, "SERVER_NOT_FOUND", format!("server {server} not found")));
2161        }
2162        let st = self
2163            .storages
2164            .get(storage)
2165            .ok_or_else(|| Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {storage} not found")))?;
2166        if st.state != "online" {
2167            return Err(Refusal::new(
2168                409,
2169                "STORAGE_STATE_ILLEGAL",
2170                format!("storage {storage} is {} — wait for online", st.state),
2171            ));
2172        }
2173        let (title, size) = (st.title.clone(), st.size_gib);
2174        let s = self.servers.get_mut(server).expect("checked above");
2175        let Some(d) = s.devices.iter_mut().find(|d| d.kind == "cdrom") else {
2176            return Err(Refusal::new(404, "CDROM_DEVICE_NOT_FOUND", format!("server {server} has no cdrom device")));
2177        };
2178        if !d.storage.is_empty() {
2179            return Err(Refusal::new(
2180                409,
2181                "CDROM_DEVICE_IN_USE",
2182                format!("the cdrom of {server} already holds {}; eject it first", d.storage),
2183            ));
2184        }
2185        d.storage = storage.to_string();
2186        d.storage_title = title;
2187        d.storage_size = size;
2188        self.engine.load(server, storage);
2189        Ok(())
2190    }
2191
2192    // ── the network ─────────────────────────────────────────────────────────
2193
2194    /// **Can `from` reach `dest:port`, from where it is standing?**
2195    ///
2196    /// There is no such thing here as "is that address up" — only "is it up
2197    /// from here". Both of the asymmetries this models (a guest with no route
2198    /// off its prefix, and a box that cannot use its own DNAT) make the answer
2199    /// depend on the asker, and a reachability call that did not name one would
2200    /// be answering a question nobody has.
2201    pub fn reach(&self, from: &str, dest: &str, port: u16) -> Answer<crate::net::Reach> {
2202        let s = self
2203            .servers
2204            .get(from)
2205            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {from} not found")))?;
2206        let listening = |addr: &str, _p: u16| {
2207            self.servers
2208                .values()
2209                .any(|x| (x.public_ip == addr || x.utility_ip == addr) && x.state == "started")
2210        };
2211        Ok(crate::net::outbound_reach(
2212            &s.utility_ip,
2213            &s.public_ip,
2214            &s.uuid,
2215            s.dhcp_client,
2216            dest,
2217            port,
2218            &self.dnat,
2219            listening,
2220        ))
2221    }
2222
2223    /// **The three paths a host key must answer identically**, which is what
2224    /// tells a re-imaged machine from a hijacked name.
2225    ///
2226    /// A re-image mints a new key, so `REMOTE HOST IDENTIFICATION HAS CHANGED`
2227    /// is expected and must not be trusted blindly. The check that makes it
2228    /// safe is that the SAME key answers on the name, on the front's public
2229    /// address, and on the appliance's OWN public address bypassing the DNAT.
2230    /// [`Fault::HijackedName`] makes the name path answer a different key,
2231    /// which is the case the whole check exists for — and a verifier that only
2232    /// looked at one path would accept it.
2233    /// **Behaviour 62: can `from_ip` reach `to`:`port` INBOUND, through the
2234    /// provider's firewall?** `Dropped` is silence (the firewall), `Refused`
2235    /// is an RST (admitted, nothing listening — a server that is not
2236    /// `started`), `Ok` is a listener.
2237    pub fn inbound(&self, from_ip: &str, to: &str, proto: &str, port: u16) -> Answer<crate::net::Reach> {
2238        let s = self
2239            .servers
2240            .get(to)
2241            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {to} not found")))?;
2242        let dest = s.public_ip.clone();
2243        if crate::net::firewall_admits(s.firewall_on, &s.rules, proto, from_ip, 0, port) == crate::net::Admit::Drop {
2244            return Ok(crate::net::Reach::Dropped { dest, port });
2245        }
2246        if s.state != "started" {
2247            return Ok(crate::net::Reach::Refused { dest, port });
2248        }
2249        Ok(crate::net::Reach::Ok)
2250    }
2251
2252    /// **Behaviour 62, the UDP half: does the reply to this server's own
2253    /// outbound query come back?** The reply is an inbound packet FROM
2254    /// `from_ip:from_port` (53 for DNS, 123 for NTP) to an ephemeral port, and
2255    /// the firewall is stateless for UDP — so a rule set ending in a catch-all
2256    /// drop eats it, and a server with its firewall off gets it. This is the
2257    /// MECHANISM behind [`Fault::UdpInboundDropped`], which stays the
2258    /// estate-wide default-ON approximation for callers that ask no server.
2259    pub fn udp_reply_arrives(&self, server: &str, from_ip: &str, from_port: u16) -> Answer<bool> {
2260        let s = self
2261            .servers
2262            .get(server)
2263            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {server} not found")))?;
2264        Ok(crate::net::firewall_admits(s.firewall_on, &s.rules, "udp", from_ip, from_port, 40_000) == crate::net::Admit::Accept)
2265    }
2266
2267    pub fn host_key_via(&self, uuid: &str, path: HostKeyPath) -> Answer<String> {
2268        let s = self
2269            .servers
2270            .get(uuid)
2271            .ok_or_else(|| Refusal::new(404, "SERVER_NOT_FOUND", format!("server {uuid} not found")))?;
2272        if s.ssh_host_key.is_empty() {
2273            return Err(Refusal::new(409, "NO_HOST_KEY", format!("server {uuid} has never been installed")));
2274        }
2275        if path == HostKeyPath::Name && self.faults.fires(Fault::HijackedName) {
2276            // A different machine, answering on the name. The only thing that
2277            // catches it is another path disagreeing.
2278            return Ok(format!("SHA256:{:016x}{:016x}", 0xdeadbeefdeadbeefu64, 0xfeedfacefeedfaceu64));
2279        }
2280        Ok(s.ssh_host_key.clone())
2281    }
2282
2283    /// A lay of the estate: every address is reshuffled. Nothing else changes —
2284    /// this is what a `xtask estate rebuild` does to the ADDRESSES, and the
2285    /// point is that the next lay's appliance is not on the last lay's IP.
2286    pub fn relay(&mut self) {
2287        let seed = self.seed;
2288        self.addresses.relay(seed);
2289    }
2290
2291    pub fn lays(&self) -> u64 {
2292        self.addresses.lays()
2293    }
2294
2295    // ── the direct-upload import, and the wait that follows it ──────────────
2296
2297    /// `POST /1.3/storage/{uuid}/import` — open a direct-upload session.
2298    ///
2299    /// The session is `prepared` and nothing has moved yet. The URL it hands
2300    /// back is the mock's own, so a caller that follows the reply (rather than
2301    /// building the URL itself) reaches the mock without being told to.
2302    pub fn start_import(&mut self, uuid: &str, source: &str) -> Answer<Import> {
2303        self.refuse_if_write_unavailable()?;
2304        let now = self.clock.now_ms();
2305        let base = self.upload_base.clone();
2306        let zone = self.zone.clone();
2307        let Some(s) = self.storages.get_mut(uuid) else {
2308            return Err(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {uuid} not found")));
2309        };
2310        // **Behaviour 66.** An import straight after `POST /storage`, before the
2311        // new storage has settled, is ACCEPTED: gunnar `deploy/upcloud`
2312        // `start_medium` (plan.rs:550-553) imports with no wait and no retry,
2313        // and the live re-image of 2026-09-21 08:42 (private-gunnar-ops
2314        // `.reimage/reinstall-receipt.json`: media-cdrom at 24.5 s,
2315        // media-virtio at 47.4 s, `done: true`) did it twice without a 409 —
2316        // as did every re-image since 2026-09-07 and the 2026-09-20 clone probe.
2317        // Whether the storage READ `maintenance` at that moment was never
2318        // recorded; that it was accepted was. Any other non-online state
2319        // (syncing, a grow, a delete) is still refused.
2320        // (T12, faithful-guest: and only while no import is open — a storage
2321        // busy with an import already is still refused.)
2322        let fresh = s.state == "maintenance" && s.import.is_none() && s.transition.as_ref().is_some_and(|t| t.then == After::Created);
2323        if s.state != "online" && !fresh {
2324            return Err(Refusal::new(
2325                409,
2326                "STORAGE_STATE_ILLEGAL",
2327                format!("storage {uuid} is {} — an import needs it online", s.state),
2328            ));
2329        }
2330        // The real one is `https://<zone>.img.upcloud.com/uploader/session/<uuid>`.
2331        // The mock's is its own socket with the same path, so the shape a caller
2332        // parses is the shape it will parse in production.
2333        let url = if base.is_empty() {
2334            // The provider's shape with an RFC 2606 host: the path a parser
2335            // must handle, and nothing a follower could reach.
2336            format!("https://{zone}.img.mock.invalid/uploader/session/{uuid}")
2337        } else {
2338            format!("{base}/uploader/session/{uuid}")
2339        };
2340        let im = Import {
2341            source: source.to_string(),
2342            state: "prepared".into(),
2343            created_ms: now,
2344            completed_ms: None,
2345            client_content_length: 0,
2346            read_bytes: 0,
2347            written_bytes: 0,
2348            md5sum: None,
2349            sha256sum: None,
2350            error_code: None,
2351            error_message: None,
2352            direct_upload_url: url,
2353        };
2354        s.import = Some(im.clone());
2355        Ok(im)
2356    }
2357
2358    /// The `PUT` of the bytes to the session URL.
2359    ///
2360    /// `read_bytes` and `written_bytes` are the REAL count and the digests are
2361    /// the REAL digests — the ladder compares `sha256sum` with the local file's,
2362    /// so a made-up value here would make the verification pass without ever
2363    /// having run. Five seconds for a 43 MB ISO, and then the storage enters
2364    /// `syncing` for a hundred and something more.
2365    pub fn upload(&mut self, uuid: &str, bytes: &[u8]) -> Answer<Import> {
2366        self.refuse_if_write_unavailable()?;
2367        let now = self.clock.now_ms();
2368        let per_mib = self.timings.import_upload_ms_per_mib;
2369        let Some(s) = self.storages.get_mut(uuid) else {
2370            return Err(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {uuid} not found")));
2371        };
2372        let Some(im) = s.import.as_mut() else {
2373            return Err(Refusal::new(404, "STORAGE_IMPORT_NOT_FOUND", format!("no import session on {uuid}")));
2374        };
2375        if im.state == "completed" || im.state == "failed" {
2376            return Err(Refusal::new(409, "STORAGE_IMPORT_ALREADY_DONE", "this session has already finished"));
2377        }
2378        let n = bytes.len() as u64;
2379        im.state = "uploading".into();
2380        im.client_content_length = n;
2381        im.read_bytes = n;
2382        im.written_bytes = n;
2383        im.md5sum = Some(crate::digest::md5_hex(bytes));
2384        im.sha256sum = Some(crate::digest::sha256_hex(bytes));
2385        let out = im.clone();
2386        // The bytes themselves, for an engine that will put them in a tray.
2387        if let Err(why) = self.engine.store_medium(uuid, bytes) {
2388            eprintln!("mock-upcloud  storage {uuid}: the engine could not keep the upload: {why}");
2389        }
2390        // The import grows the storage to fit the image it received — which is
2391        // why the seed volume only has to be LEGAL, not large.
2392        let gib = n.div_ceil(1024 * 1024 * 1024).max(1);
2393        s.size_gib = s.size_gib.max(gib);
2394        let upload_ms = (n / (1024 * 1024)).max(1) * per_mib;
2395        s.state = "maintenance".into();
2396        s.transition = Some(Transition { to: "syncing".into(), at_ms: now + upload_ms, then: After::BeginSync });
2397        Ok(out)
2398    }
2399
2400    /// **`POST /1.3/storage/{uuid}/clone` — the candidate fix.**
2401    ///
2402    /// The ladder uploads the same 43 MB medium twice per re-image, once as a
2403    /// CD-ROM and once as a virtio disk, because `korp-installer` probes virtio
2404    /// and nothing else. The second one could be a clone of the first: the same
2405    /// bytes, the same sha256, no second upload.
2406    ///
2407    /// **Behaviour 53, MEASURED 2026-09-20** (gunnar `clone_probe.rs`): the call
2408    /// is quick, then `maintenance` → `online` in 47 s with NO `syncing`. This
2409    /// doc used to say the answer was not known; it was, one repository over.
2410    /// And the same probe says why it is still not the fix: a clone cannot
2411    /// start until its source is `online`, so import→clone is serial (161 s)
2412    /// where two parallel imports are ~115 s. [`Fault::CloneSyncsLikeImport`]
2413    /// keeps the old pessimistic guess, by name.
2414    pub fn clone_storage(&mut self, uuid: &str, title: &str) -> Answer<String> {
2415        self.refuse_if_write_unavailable()?;
2416        let now = self.clock.now_ms();
2417        let prepare = self.timings.clone_prepare_ms;
2418        let (lo, hi) = (self.timings.clone_sync_lo_ms, self.timings.clone_sync_hi_ms);
2419        let old_guess = self.faults.fires(Fault::CloneSyncsLikeImport);
2420        let online_ms = self.timings.clone_online_ms;
2421        let src = self
2422            .storages
2423            .get(uuid)
2424            .ok_or_else(|| Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {uuid} not found")))?;
2425        if src.state != "online" {
2426            return Err(Refusal::new(
2427                409,
2428                "STORAGE_STATE_ILLEGAL",
2429                format!("storage {uuid} is {} — a clone needs it online", src.state),
2430            ));
2431        }
2432        let (size, tier, zone, labels) = (src.size_gib, src.tier.clone(), src.zone.clone(), src.labels.clone());
2433        // A clone carries the SAME BYTES, so it carries the same digests. That
2434        // is the whole appeal: the ladder can verify the second medium against
2435        // the same local sha256 without uploading it again.
2436        let digests = src.import.as_ref().map(|i| (i.md5sum.clone(), i.sha256sum.clone(), i.read_bytes));
2437        let new = self.mint_uuid();
2438        let mut r = SplitMix64::derive(self.seed, &format!("clone/{new}"));
2439        let sync_ms = r.range(lo, hi);
2440        let import = digests.map(|(md5, sha, n)| Import {
2441            source: "storage".into(),
2442            state: "completed".into(),
2443            created_ms: now,
2444            completed_ms: Some(now),
2445            client_content_length: n,
2446            read_bytes: n,
2447            written_bytes: n,
2448            md5sum: md5,
2449            sha256sum: sha,
2450            error_code: None,
2451            error_message: None,
2452            direct_upload_url: String::new(),
2453        });
2454        let transition = if old_guess {
2455            Transition { to: "syncing".into(), at_ms: now + prepare, then: After::OnlineIn { ms: sync_ms } }
2456        } else {
2457            Transition { to: "online".into(), at_ms: now + online_ms, then: After::Nothing }
2458        };
2459        self.storages.insert(
2460            new.clone(),
2461            Storage {
2462                uuid: new.clone(),
2463                title: title.to_string(),
2464                size_gib: size,
2465                tier,
2466                zone,
2467                state: "maintenance".into(),
2468                kind: StorageKind::Normal,
2469                labels,
2470                origin: Some(uuid.to_string()),
2471                created_ms: now,
2472                import,
2473                transition: Some(transition),
2474            },
2475        );
2476        Ok(new)
2477    }
2478
2479    /// **Behaviour 44: the account's storage quota.** MEASURED: yvra's
2480    /// `storage_maxiops` limit is 10 240 GiB (`/1.3/account`). The refusal's
2481    /// shape, `403 MAXIOPS_STORAGE_LIMIT_REACHED`, is REPORTED (monetize-impl
2482    /// `api.rs`), not measured. Only MaxIOPS is counted: the other tiers'
2483    /// limits were never read.
2484    fn refuse_over_quota(&self, uuid: &str, extra_gib: u64) -> Answer<()> {
2485        let tier = self.storages.get(uuid).map(|s| s.tier.clone()).unwrap_or_else(|| "maxiops".into());
2486        self.refuse_over_quota_for(&tier, extra_gib)
2487    }
2488
2489    fn refuse_over_quota_for(&self, tier: &str, extra_gib: u64) -> Answer<()> {
2490        if tier != "maxiops" {
2491            return Ok(());
2492        }
2493        let used: u64 = self
2494            .storages
2495            .values()
2496            .filter(|s| s.tier == "maxiops" && s.kind != StorageKind::Template)
2497            .map(|s| s.size_gib)
2498            .sum();
2499        if used + extra_gib > self.maxiops_quota_gib {
2500            return Err(Refusal::new(
2501                403,
2502                "MAXIOPS_STORAGE_LIMIT_REACHED",
2503                format!(
2504                    "the account's MaxIOPS quota is {} GiB; {used} GiB is used and {extra_gib} GiB more was asked for",
2505                    self.maxiops_quota_gib
2506                ),
2507            ));
2508        }
2509        Ok(())
2510    }
2511
2512    fn refuse_if_write_unavailable(&self) -> Answer<()> {
2513        if self.faults.fires(Fault::WriteUnavailable) {
2514            return Err(Refusal::new(503, "SERVICE_UNAVAILABLE", "the service is temporarily unavailable"));
2515        }
2516        Ok(())
2517    }
2518
2519    /// Everything, for the mock's own `/mock/estate` view and for the tests.
2520    pub fn all_servers(&self) -> impl Iterator<Item = &Server> {
2521        self.servers.values()
2522    }
2523    pub fn all_storages(&self) -> impl Iterator<Item = &Storage> {
2524        self.storages.values()
2525    }
2526}
2527
2528/// **`511 HOTPLUG_FAILED`** — the guest never acked the ACPI eject/insert.
2529/// MEASURED 2026-09-14 (memory: upcloud-reimage-install-loop,
2530/// tunnr-kernel-hotplug). Behaviour 60.
2531fn hotplug_failed(server: &str) -> Refusal {
2532    Refusal::new(
2533        511,
2534        "HOTPLUG_FAILED",
2535        format!("server {server}: the guest did not acknowledge the hot-plug request"),
2536    )
2537}
2538
2539/// **The timing rule, proven against a machine the test controls.**
2540///
2541/// The API reports UpCloud's time; the VM runs at its own speed. So a modelled
2542/// state is HELD even when a real guest finishes early, and the API never says
2543/// `stopped` while the guest still runs. Behaviours 45 and 46 are the modelled
2544/// half (tests/ledger.rs); these are the reconciliation with a real guest.
2545#[cfg(test)]
2546mod timeline_tests {
2547    use super::*;
2548    use crate::kvm::{GuestEngine, GuestOutcome, GuestSpec, Machine};
2549    use std::sync::Mutex;
2550
2551    /// A machine whose power the TEST holds: it runs from `power_on` until the
2552    /// test (the "guest") or the estate (a stop) turns it off.
2553    #[derive(Default)]
2554    struct Hand(Mutex<BTreeMap<String, bool>>);
2555    impl Hand {
2556        fn guest_powers_off(&self, s: &str) {
2557            self.0.lock().unwrap().insert(s.into(), false);
2558        }
2559    }
2560    impl GuestEngine for Hand {
2561        fn name(&self) -> &'static str {
2562            "hand"
2563        }
2564        fn boot(&self, _: &GuestSpec) -> GuestOutcome {
2565            unreachable!("the estate drives power_on, not boot")
2566        }
2567        fn power_on(&self, m: &Machine) -> Result<(), String> {
2568            self.0.lock().unwrap().insert(m.server.clone(), true);
2569            Ok(())
2570        }
2571        fn power_off(&self, s: &str, _hard: bool) {
2572            self.0.lock().unwrap().insert(s.into(), false);
2573        }
2574        fn running(&self, s: &str) -> Option<bool> {
2575            self.0.lock().unwrap().get(s).copied()
2576        }
2577    }
2578
2579    fn rig() -> (Estate, Arc<Hand>) {
2580        let hand = Arc::new(Hand::default());
2581        let e = Estate::new(Clock::virtual_only(), Faults::quiet(), 7).with_engine(hand.clone());
2582        (e, hand)
2583    }
2584
2585    /// A stopped server with an installer medium first in the boot order.
2586    fn an_installer(e: &mut Estate) -> String {
2587        let u = e.create_server("app", "app", "2xCPU-4GB", "se-sto1", vec![], "boot", 20).unwrap();
2588        e.run_to_quiet();
2589        e.stop_server(&u, true).unwrap();
2590        e.run_to_quiet();
2591        let iso = e.create_storage("iso", 1, "maxiops", "se-sto1", vec![]).unwrap();
2592        e.run_to_quiet();
2593        e.attach(&u, &iso, "cdrom").unwrap();
2594        e.modify_server(&u, None, Some(BootOrder::CdromDisk), None, None, None).unwrap();
2595        u
2596    }
2597
2598    fn advance_to(e: &mut Estate, t: u64) {
2599        let now = e.clock.now_ms();
2600        if t > now {
2601            e.clock.advance_ms(t - now);
2602        }
2603        e.settle();
2604    }
2605
2606    /// The VM installs in "2 s" and powers off; the API still reads
2607    /// `maintenance` until the measured 130–146 s pass is over.
2608    #[test]
2609    fn a_guest_that_finishes_early_does_not_make_the_api_early() {
2610        let (mut e, hand) = rig();
2611        let u = an_installer(&mut e);
2612        let t0 = e.clock.now_ms();
2613        e.start_server(&u).unwrap();
2614        { let t = e.timings; advance_to(&mut e, t0 + t.start_ms); }
2615        assert_eq!(hand.running(&u), Some(true), "the machine is up");
2616        { let t = e.timings; advance_to(&mut e, t0 + t.start_ms + 2_000); }
2617        hand.guest_powers_off(&u);
2618        { let t = e.timings; advance_to(&mut e, t0 + t.install_pass_lo_ms - 1); }
2619        assert_eq!(e.server(&u).unwrap().state, "maintenance", "the API reports UpCloud's pass, not the VM's 2 s");
2620        { let t = e.timings; advance_to(&mut e, t0 + t.install_pass_hi_ms); }
2621        assert_eq!(e.server(&u).unwrap().state, "stopped");
2622        assert_eq!(e.server(&u).unwrap().guest, Guest::Installed);
2623    }
2624
2625    /// The VM outlives the modelled pass: the API does NOT say `stopped` over
2626    /// it, and says it within the notice once the machine is gone.
2627    #[test]
2628    fn the_api_never_says_stopped_while_the_vm_runs() {
2629        let (mut e, hand) = rig();
2630        let u = an_installer(&mut e);
2631        let t0 = e.clock.now_ms();
2632        e.start_server(&u).unwrap();
2633        // Step to the hypervisor start and through the brief `started` first:
2634        // a transition chains from the moment it is SETTLED, so one jump would
2635        // start the pass late.
2636        { let t = e.timings; advance_to(&mut e, t0 + t.installer_started_ms); }
2637        { let t = e.timings; advance_to(&mut e, t0 + t.installer_started_ms + t.installer_started_window_ms); }
2638        { let t = e.timings; advance_to(&mut e, t0 + t.install_pass_hi_ms + 60_000); }
2639        assert_eq!(hand.running(&u), Some(true));
2640        assert_eq!(e.server(&u).unwrap().state, "maintenance", "held: the VM still runs");
2641        let off = e.clock.now_ms();
2642        hand.guest_powers_off(&u);
2643        { let t = e.timings; advance_to(&mut e, off + t.poweroff_notice_ms); }
2644        assert_eq!(e.server(&u).unwrap().state, "stopped", "noticed within poweroff_notice_ms");
2645    }
2646
2647    /// A disk-booted guest that powers itself off is noticed, not mirrored:
2648    /// `started` for the notice window, then `stopped` — never before the VM.
2649    #[test]
2650    fn a_self_powered_off_guest_is_noticed_after_the_notice_window() {
2651        let (mut e, hand) = rig();
2652        let u = e.create_server("front", "front", "2xCPU-4GB", "se-sto1", vec![], "boot", 20).unwrap();
2653        e.run_to_quiet();
2654        assert_eq!(e.server(&u).unwrap().state, "started");
2655        let off = e.clock.now_ms();
2656        hand.guest_powers_off(&u);
2657        e.settle();
2658        assert_eq!(e.server(&u).unwrap().state, "started", "the API runs behind the machine");
2659        { let t = e.timings; advance_to(&mut e, off + t.poweroff_notice_ms); }
2660        assert_eq!(e.server(&u).unwrap().state, "stopped");
2661        assert_eq!(e.server(&u).unwrap().guest, Guest::Off);
2662    }
2663
2664    /// **An ejected tray is a disk boot, whatever the boot order says.** With
2665    /// `cdrom,disk` and a medium in the tray a start is an installer pass
2666    /// (`maintenance` until `stopped`, never `started`, 46); eject the medium,
2667    /// leave the order alone, and the same start reads `started` at ~10 s
2668    /// (L52, L54). The device row survives the eject, which is what fooled
2669    /// the old check: T3's TK run timed out on exactly this, with gRPC up.
2670    #[test]
2671    fn an_ejected_tray_boots_the_disk_even_with_cdrom_first() {
2672        let (mut e, hand) = rig();
2673        let iso = e.create_storage("medium", 1, "maxiops", "se-sto1", vec![]).unwrap();
2674        let u = e.create_server("appliance", "appliance", "2xCPU-4GB", "se-sto1", vec![], "boot", 20).unwrap();
2675        e.run_to_quiet();
2676        e.stop_server(&u, true).unwrap();
2677        e.run_to_quiet();
2678        e.attach(&u, &iso, "cdrom").unwrap();
2679        e.modify_server(&u, None, Some(BootOrder::Cdrom), None, None, None).unwrap();
2680
2681        // Medium in, CD first: the installer's timeline — `started` only for
2682        // the brief window at 3.6 s (receipt 2026-09-21), then `maintenance`.
2683        let t0 = e.clock.now_ms();
2684        e.start_server(&u).unwrap();
2685        { let t = e.timings; advance_to(&mut e, t0 + t.installer_started_ms - 1); }
2686        assert_eq!(e.server(&u).unwrap().state, "maintenance");
2687        { let t = e.timings; advance_to(&mut e, t0 + t.installer_started_ms); }
2688        assert_eq!(e.server(&u).unwrap().guest, Guest::Installing);
2689        assert_eq!(e.server(&u).unwrap().state, "started", "the installer's brief started");
2690        { let t = e.timings; advance_to(&mut e, t0 + t.installer_started_ms + t.installer_started_window_ms); }
2691        assert_eq!(e.server(&u).unwrap().state, "maintenance", "the pass runs in maintenance");
2692        { let t = e.timings; advance_to(&mut e, t0 + t.start_ms + 1); }
2693        assert_eq!(e.server(&u).unwrap().state, "maintenance", "and does not read started again");
2694        hand.guest_powers_off(&u);
2695        e.run_to_quiet();
2696        assert_eq!(e.server(&u).unwrap().state, "stopped");
2697
2698        // Eject; the order still says cdrom,disk and the device row stays.
2699        e.eject(&u).unwrap();
2700        assert!(e.server(&u).unwrap().devices.iter().any(|d| d.kind == "cdrom"), "the device survives an eject");
2701        assert!(e.server(&u).unwrap().boot_order.cdrom_first());
2702        let t1 = e.clock.now_ms();
2703        e.start_server(&u).unwrap();
2704        { let t = e.timings; advance_to(&mut e, t1 + t.start_ms - 1); }
2705        assert_eq!(e.server(&u).unwrap().state, "maintenance");
2706        { let t = e.timings; advance_to(&mut e, t1 + t.start_ms); }
2707        assert_eq!(e.server(&u).unwrap().state, "started", "an empty tray falls through to the disk");
2708        assert_ne!(e.server(&u).unwrap().guest, Guest::Installing);
2709    }
2710
2711    /// The modelled floor for a disk boot is ~10 s, whatever the VM does.
2712    #[test]
2713    fn a_disk_boot_reads_started_at_the_modelled_ten_seconds() {
2714        let (mut e, _hand) = rig();
2715        let u = e.create_server("front", "front", "2xCPU-4GB", "se-sto1", vec![], "boot", 20).unwrap();
2716        e.run_to_quiet();
2717        e.stop_server(&u, true).unwrap();
2718        e.run_to_quiet();
2719        let t0 = e.clock.now_ms();
2720        e.start_server(&u).unwrap();
2721        { let t = e.timings; advance_to(&mut e, t0 + t.start_ms - 1); }
2722        assert_eq!(e.server(&u).unwrap().state, "maintenance");
2723        { let t = e.timings; advance_to(&mut e, t0 + t.start_ms); }
2724        assert_eq!(e.server(&u).unwrap().state, "started");
2725    }
2726}
2727
2728#[cfg(test)]
2729mod address_tests {
2730    use super::*;
2731    use crate::{Clock, Faults};
2732
2733    /// **The pool holds no literal outside TEST-NET.** A real address here
2734    /// once pointed the mock's ssh at a stranger's port 22.
2735    #[test]
2736    fn the_public_pool_is_test_net_and_nothing_else() {
2737        let a = Addresses::new(7);
2738        assert!(!a.public.is_empty());
2739        for ip in &a.public {
2740            assert!(is_test_net(ip), "public pool holds {ip}, which is not RFC 5737 TEST-NET");
2741        }
2742        for ip in &a.utility {
2743            assert!(is_utility_pool(ip), "utility pool holds {ip}");
2744            assert!(ip.starts_with("10."), "utility must be RFC 1918: {ip}");
2745        }
2746        // The checks themselves can say no.
2747        assert!(!is_test_net("94.237.30.222") && !is_test_net("81.27.106.241") && !is_test_net("192.0.3.1"));
2748        assert!(is_test_net("192.0.2.10") && is_test_net("198.51.100.17") && is_test_net("203.0.113.1"));
2749    }
2750
2751    /// **The estate can NEVER hand out an address outside TEST-NET or its
2752    /// utility range** — over many servers, many lays and several seeds, and
2753    /// behaviour 12 still holds: the addresses move between lays.
2754    #[test]
2755    fn the_estate_never_hands_out_a_routable_public_address() {
2756        for seed in [0u64, 1, 41, 1234, 0xdead_beef] {
2757            let mut e = Estate::new(Clock::virtual_only(), Faults::none(), seed);
2758            let mut firsts = Vec::new();
2759            for lay in 0..6 {
2760                if lay > 0 {
2761                    e.relay();
2762                }
2763                for i in 0..30 {
2764                    let u = e.create_server(&format!("s{lay}-{i}"), "h", "1xCPU-1GB", "se-sto1", vec![], "boot", 1).unwrap();
2765                    let s = e.server(&u).unwrap();
2766                    assert!(is_test_net(&s.public_ip), "seed {seed}: public {} is routable", s.public_ip);
2767                    assert!(is_utility_pool(&s.utility_ip), "seed {seed}: utility {} is outside the pool", s.utility_ip);
2768                    if i == 0 {
2769                        firsts.push(s.public_ip.clone());
2770                    }
2771                }
2772            }
2773            firsts.dedup();
2774            assert!(firsts.len() > 1, "seed {seed}: the first address never moved across lays (behaviour 12)");
2775        }
2776    }
2777}