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}