Skip to main content

mock_upcloud/
lib.rs

1//! **`mock-upcloud` — the UpCloud API 1.3, faithfully wrong.**
2//!
3//! A real HTTP server that answers UpCloud's 1.3 surface out of a state machine.
4//! Point `UPCLOUD_API_BASE` at it and the real `monetize-cloud-impl` upcloud
5//! plugin, the real `gunnar/deploy/upcloud` procedure and the real `cargo xtask`
6//! estate verbs talk to it without knowing. Nothing here reaches the network.
7//!
8//! # The point is the lies, not the happy path
9//!
10//! A mock that answers every call correctly proves nothing, because that is not
11//! the provider anybody has. Every behaviour below was MEASURED against the live
12//! account or is written down in this estate's history (3, 5 and 37 are the
13//! exception: verified against Scaleway, not UpCloud — see below), and each one is armable
14//! on its own ([`faults::Fault`]) so a defect can be reproduced on demand instead
15//! of waited for.
16//!
17//! | # | behaviour | where it is modelled |
18//! |---|---|---|
19//! | 1 | a firewall-rule read for a DELETED server answers `403 ERROR_AUTHENTICATION_FAILED`, not 404 | [`http::firewall_rule`], always on — it is not a fault, it is the API |
20//! | 2 | `GET /1.3/price` fails at the TRANSPORT level, not with a status | [`Fault::PriceTransportReset`] — the socket is closed mid-reply |
21//! | 3 | `412 out_of_stock` on poweron, for days — **verified against Scaleway (fr-par-1 2026-09-09), not UpCloud; kept as a generic cloud-provider fault** (UpCloud documents `409 STORAGE_RESOURCES_UNAVAILABLE`) | [`Fault::OutOfStock`], sticky until disarmed |
22//! | 4 | the server LIST carries no `storage_devices`; the storage list DOES carry `labels` | [`render`], always on |
23//! | 5 | a revoked credential answers 0 servers and 403 on some endpoints, never a clean 401 — **verified against Scaleway (IAM change 2026-08-26), not UpCloud; kept as a generic cloud-provider fault** (a dead UpCloud token is a clean 401: 43) | [`Fault::RevokedCredential`] |
24//! | 6 | create a server: 98–105 s | [`clock::Timings::server_create`] |
25//! | 7 | delete front/twin ≈ 60 s; delete an appliance carrying 4 member volumes > 5 min in `maintenance` | [`clock::Timings::server_delete`] |
26//! | 8 | the guest's own install ≈ 10 s (`INSTALL-OK` 10 011 ms measured under KVM) | [`clock::Timings::guest_install`] / the KVM half |
27//! | 9 | `stop-resize-start` leaves an auto-titled `Resize Backup`, detached, labelled, WITHOUT "gunnar" in the title | [`estate::Estate::modify_server`] |
28//! | 10 | ~~young objects are indistinguishable from orphans without `created`~~ **WRONG, and corrected below** | `created` is SENT, on both the list and the detail; [`Fault::WithholdCreatedField`] takes it away on request |
29//! | 11 | a server must be stopped (hard) before it can be deleted with its storages | [`estate::Estate::delete_server`] |
30//! | 12 | utility IPs are reassigned between re-lays; public IPs change every time | [`estate::Addresses`], shuffled per lay from the seed |
31//! | 13 | no serial console — VNC only; a PID-1 panic is invisible except on the framebuffer | the KVM half boots `-serial none` |
32//! | 14 | the VNC port goes stale on every stop/start and the API keeps reporting the old one | [`Fault::StaleVncPort`] (default ON — it is the provider's normal) |
33//! | 15 | SeaBIOS, no UEFI | the KVM half refuses an OVMF spec by name |
34//! | 16 | `cdrom/eject` works on a STARTED server; IDE/virtio detach does not (the virtio half is the guest's — see 60) | [`estate::Estate::eject`] / [`estate::Estate::detach`] |
35//! | 17 | a re-image killed mid-install loops the installer (CD stays first in the boot order) | [`estate::Guest::boot`] |
36//! | 18 | inbound UDP replies are dropped (DNS/NTP) | [`Fault::UdpInboundDropped`], default ON; [`guest_clock::ntp_answer`] answers silence |
37//! | 19 | a guest boots one TIMEZONE OFFSET ahead — +2 h in summer, +1 h in winter — and not every guest does | [`guest_clock`]: the hypervisor's RTC is correct UTC, the GUEST misreads it ([`Fault::GuestReadsRtcAsLocalTime`]) |
38//! | 20 | the installer PID 1 brings no network up, so nothing narrates the install window | the KVM half's install image |
39//! | 21 | on first boot `unattended-upgrade-shutdown --wait-for-signal` holds `dpkg lock-frontend` for the life of the boot | the Ubuntu template guest |
40//! | 22 | a volume NEVER shrinks; a growth is permanent and billed forever | [`estate::Estate::modify_storage`] refuses a shrink by name |
41//! | 23 | the direct-upload import and the storage have **two different clocks**: the import says `completed` five seconds in, the storage sits in `syncing` for 100–130 s more | [`estate::Estate::upload`] → [`estate::After::BeginSync`] |
42//! | 24 | the import object carries `read_bytes`/`written_bytes` and REAL `md5sum`/`sha256sum` — the ladder verifies the sha256 against the local file | [`digest`], which computes them rather than inventing them |
43//! | 25 | the same 43 MB medium is uploaded TWICE per re-image (cdrom, then virtio, because `korp-installer` probes virtio alone) — two imports, two syncs, ~4 min of pure waiting | [`clock::Timings::storage_sync_lo_ms`] |
44//! | 26 | `POST /storage/{uuid}/clone` is the candidate fix, and whether it skips the sync is **MEASURED after all — see 53** | [`estate::Estate::clone_storage`]; pessimistic by default, [`Fault::CloneSyncsLikeImport`] for the optimistic guess |
45//! | 27 | one wrong clock is SIX red rows: skew-refusal → no-quorum → clock-unchanged → console-key → banner → monetize-poll | [`guest_clock::cascade`] |
46//! | 28 | **the skew window is never widened**: a row that needs a wider window is a RED | [`guest_clock::widening_the_window_still_leaves_a_red`] — no number turns a wrong clock green |
47//! | 29 | **DHCP option 121 is offered and a guest that ignores it has NO ROUTE off its /22** — outbound dies, inbound stays healthy | [`net::outbound_reach`] / [`Fault::GuestIgnoresDhcpOption121`] |
48//! | 30 | **hairpin NAT does not exist**: the box holding a DNAT cannot use it on its own public address | [`net::Reach::NoHairpin`] — and it reads `connection refused` |
49//! | 31 | the console is a HOST and a port, and the toggle re-provisions BOTH (`se-sto1.vnc.upcloud.com:60031`) | [`estate::Server::console`], [`render::server`] |
50//! | 32 | the guest's install is 2–5 s on the UART while the ladder's wall measurement of the same re-image is 110–253 s | [`clock::Timings::reimage_observers`] — two observers, two orders of magnitude |
51//! | 33 | a re-image mints a NEW SSH host key every time; three paths must agree to tell that from a hijacked name | [`estate::Estate::host_key_via`] / [`Fault::HijackedName`] |
52//! | 34 | **the door that actually leaks**: `POST /storage/{uuid}/resize` (the twin's xfs growth) takes a `Resize Backup` FIRST, the size of the volume, and hands it back as `resize_backup`; nothing deletes it. Labelled by default (measured); [`Fault::ResizeBackupUnlabelled`] is the brief's hypothesis that it carries nothing at all | [`estate::Estate::resize_filesystem`] |
53//! | 35 | the firewall of a server that READS FINE answers the same `403 ERROR_AUTHENTICATION_FAILED` as behaviour 1 — a credential scoped without the firewall permission; only the server's own detail tells "forbidden" from "gone" | [`Fault::FirewallForbidden`], by name only (INFERRED, not measured) |
54//! | 36 | `GET /1.3/server/{uuid}` answers `404 SERVER_NOT_FOUND` for a uuid the LIST carries — the account contradicting itself | [`Fault::DetailNotFoundForListedServer`], by name only (REPORTED 2026-09-20, uuids never compared before the box was wiped) |
55//! | 37 | a sold-out zone refuses the **create** with the same `412 out_of_stock` it refuses the poweron with, and mints nothing — **verified against Scaleway (fr-par-1 2026-09-09), not UpCloud; kept as a generic cloud-provider fault** | [`estate::Estate::create_server`] / [`Fault::OutOfStock`], one sticky fault for both doors |
56//! | 38 | a detach answers `200` **and the volume stays attached** — the write that reports its own success and did not happen; only a read-back tells it from a real one | [`estate::Estate::detach`] / [`Fault::DetachSaysSuccessButStaysAttached`], by name only (REPORTED) |
57//! | 39 | an empty path segment (`/1.3//storage`) is a `404` that names no path | [`http`], always on — gunnar `api.rs`, MEASURED |
58//! | 40 | a READ answers `502` now and then; a poll must survive it | [`Fault::ReadBadGateway`], by name (REPORTED, gunnar `wait.rs`) |
59//! | 41 | `PUT /1.3/server/{uuid}` answers `202` | [`http`], always on — MEASURED 2026-09-19 |
60//! | 42 | a chunked PUT to the direct-upload URL answers `411` | [`http`], always on (REPORTED, gunnar `http.rs`) |
61//! | 43 | a DEAD token is a clean `401 AUTHENTICATION_FAILED` on every call | [`Fault::DeadToken`] — MEASURED 2026-09-14 (ROTATION §2.3) |
62//! | 44 | past the account's MaxIOPS quota (yvra: 10 240 GiB) a create or grow is `403 MAXIOPS_STORAGE_LIMIT_REACHED` | [`estate::Estate::create_storage`] / `modify_storage`, always on (limit MEASURED, code REPORTED) |
63//! | 45 | a disk boot reads `started` ~10 s after the start (was 20 s here) | [`clock::Timings::start_ms`] — MEASURED 2026-09-19 |
64//! | 46 | **an installer that powers the box off never reads `started`**: `maintenance` t+10…t+130 s, then `stopped` | [`clock::Timings::install_pass_lo_ms`], [`estate::After::GuestPoweredOff`] — MEASURED 2026-09-15/19 |
65//! | 47 | `stop_server` with `timeout_action` is a `400` (it belongs to restart) | [`http`], always on — MEASURED 2026-09-14 |
66//! | 48 | a plan change is done when the PUT answers (the provider starts at once); a `start` during `maintenance` is `409` | [`estate::Estate::modify_server`] / `start_server` — the provider's order MEASURED 2026-09-21 |
67//! | 49 | a `boot_order` PUT does not end an install loop; it is read at the next HYPERVISOR start | always on, now tested — MEASURED 2026-09-14 |
68//! | 50 | the VNC toggle settles ~2 s after the "no"; a "yes" inside the window gives the OLD endpoint back | [`clock::Timings::vnc_settle_ms`] — MEASURED (gunnar `console.rs`) |
69//! | 51 | the console toggle is `409` while the server is `maintenance` | [`estate::Estate::modify_server`] — MEASURED (memory: upcloud-vnc-port-toggle) |
70//! | 52 | an import's storage ends `syncing` → `maintenance` → `online` | [`clock::Timings::sync_tail_maintenance_ms`] — sequence MEASURED, split not |
71//! | 53 | **a clone is `maintenance` → `online` in 47 s with NO `syncing`** (26's "not measured" was measured) | [`clock::Timings::clone_online_ms`]; the old guess is [`Fault::CloneSyncsLikeImport`] — MEASURED 2026-09-20 |
72//! | 54 | a shrink AND a same-size PUT are `400 SIZE_INVALID` "The new size must be greater than the old size." | [`estate::Estate::modify_storage`] — MEASURED 2026-09-14 (22's code was wrong) |
73//! | 55 | a size grow is `maintenance` for 37 s, then `online` | [`clock::Timings::storage_grow_ms`] — MEASURED 2026-09-14 |
74//! | 56 | a grow of a storage attached to a RUNNING server is `409 STORAGE_ATTACHED` (terraform 5.44.1 sends it without stopping) | always on; [`Fault::ResizeRequiresDetach`] for the stricter docs page — REPORTED (docs) |
75//! | 57 | a storage already attached is `409 STORAGE_ATTACHED` on a second attach | [`estate::Estate::attach_at`] — REPORTED |
76//! | 58 | a requested address is honoured, `virtio` takes the first FREE slot, and the guest names virtio disks by slot order, contiguously (vda = virtio:0) | [`estate::Estate::attach_at`], [`estate::Estate::guest_disk_names`], `/mock/disks/{uuid}` |
77//! | 59 | a storage in `maintenance`/`syncing`, or the source of a clone still being made, cannot be deleted: `409 STORAGE_STATE_ILLEGAL` | [`estate::Estate::delete_storage`] — REPORTED |
78//! | 60 | **virtio hot-plug is the GUEST's**: a hot-plug kernel (needs `PCI_MSI`) takes an attach/detach on a running server; one without answers `511 HOTPLUG_FAILED` (16's `409 VIRTIO_HOTPLUG_UNSUPPORTED` was invented) | [`Fault::GuestKernelLacksHotplug`], per server at create — MEASURED 2026-09-14 |
79//! | 61 | an interface that asks for `IPv6` gets IPv6 ("no IPv6" is the estate's law, not UpCloud's) | [`render::server`] — REPORTED |
80//! | 62 | **the firewall is evaluated**: off or no rules = open; top-down first match; a drop is silence; the utility net is filtered; UDP replies die behind a catch-all drop | [`net::firewall_admits`], `/mock/inbound/{uuid}`, `/mock/udp-reply/{uuid}` — MEASURED 2026-09-13/14/20 |
81//! | 63 | the mock says whom it heard (per credential digest), so a terraform verb can prove the provider talked to THIS mock before `apply`/`destroy` | [`http::Mock::heard_from`], `/mock/heard?token_sha256=` |
82//! | 64 | a server PUT changes `hostname`/`title` in place (the mock dropped both; terraform refused its own apply) | [`estate::Estate::rename_server`] — MEASURED 2026-09-21 through 5.44.1 |
83//! | 65 | `POST /server/{uuid}/cdrom/load` puts a medium into an EMPTY tray; a loaded one is `409 CDROM_DEVICE_IN_USE` | [`http`] — L110, DOCUMENTED, not measured |
84//! | 66 | an import straight after `POST /storage` (no wait) is accepted; any other non-online state is still refused | [`estate::Estate::start_import`], [`estate::After::Created`] — acceptance MEASURED (live receipt 2026-09-21, gunnar `plan.rs:550`); the state it read then is not recorded |
85//! | 67 | the direct-upload `PUT` answers the import object BARE (`{"written_bytes": …, "sha256sum": …}`), not wrapped in `storage_import` | [`http`] — MEASURED: the live re-image of 2026-09-21 08:42 read it at the top level, `done: true` |
86//! | 68 | **the verified spellings**: a storage `size` is sent as a JSON NUMBER (verified live, monetize grow 2026-09-14), a stop `timeout` as a STRING (verified live, gunnar-upcloud + monetize); the other spelling of each is UNVERIFIED, so the mock accepts both rather than invent an answer | [`http`] — ledger L112 |
87//!
88//! Rows 39–63 are lane T13's, from `BEHAVIOURS-LEDGER.md` beside this crate,
89//! where every one carries its source. Three rows above are **not UpCloud's**:
90//! 3 and 37 (`412 out_of_stock`) and 5 ("0 rows + 403") were verified against
91//! Scaleway (fr-par-1 2026-09-09; the IAM change 2026-08-26), not UpCloud.
92//! Owner ruling 2026-09-21: they are kept as generic cloud-provider faults —
93//! relabelled, not removed, because removing them would re-map every storm
94//! seed — and they are never to be read as UpCloud behaviour.
95//!
96//! Behaviours 35 and 36 exist for ONE caller: `private-gunnar-ops`'s
97//! `estate::prune_dead_firewalls` and the row F38 that proves it. Together with
98//! 1 and 5 they are the four ways a `403`/`404` pair can lie about whether a
99//! server is there, and a caller that reads the list, the detail AND the
100//! firewall before it drops a state entry is the only caller that survives all
101//! four. A stale state naming a server that was re-laid under a NEW uuid — the
102//! likeliest reading of 2026-09-20 — needs no fault at all: create the estate,
103//! ask about a uuid that was never created, and the mock answers 404 on the
104//! server and 403 on its firewall while the list shows the same titles alive.
105//!
106//! # Two callers, one API
107//!
108//! Everything above is the surface `monetize-cloud-impl`'s plugin and
109//! `gunnar/deploy/upcloud` drive. `private-holger-ops` drives the SAME account
110//! through terraform and `UpCloudLtd/upcloud` 5.44, which carries
111//! `UPCLOUD_DEBUG_API_BASE_URL` — so the real declaration and the real provider
112//! can be pointed here too, and holger's twin, which has never been created
113//! because creating it costs money, can be laid down for nothing. That door is
114//! [`tf`], and the first thing it proved was about this crate rather than about
115//! holger: a device's `storage_size` was rendered as a STRING here, the
116//! official provider's own SDK declares it `int`, and every terraform server
117//! create died on a shape the account cannot send. Behaviour 10's lesson, a
118//! second time.
119//!
120//! # The calibration
121//!
122//! Every fault above is a claim that the mock can be wrong in a particular way,
123//! and a claim nobody has checked is a claim. [`self_check`] drives EVERY
124//! variant in [`Fault::ALL`] twice — disarmed and armed — and fails if the two
125//! observations are the same, because a fault that changes nothing a caller can
126//! see advertises a coverage this crate does not have. `mock-upcloud
127//! --self-check` prints the table and exits non-zero. Behaviour 38 is what it
128//! is for: `Estate::detach` had no path that answered `200` without removing
129//! the device, so every sweep driven against this mock passed a test the mock
130//! could not fail.
131//!
132//! # Time
133//!
134//! Every duration above is real, and running 100 000 of them at real speed is
135//! three years. So the mock keeps a **virtual clock** ([`clock::Clock`]) with a
136//! `speed` knob: `1.0` is faithful wall time, `0.0` makes every transition
137//! instant, `100.0` is a hundred times quicker. The ORDER and the RELATIVE
138//! durations never change — a storage still goes `maintenance` → `maintenance` →
139//! `online`, a delete still traverses `maintenance`, and a client that does not
140//! poll still fails. What changes is only how long the harness waits.
141//!
142//! This is the reason the mock is a state machine over a clock rather than a
143//! table of canned replies: a canned reply cannot be *early*.
144//!
145//! # Faults
146//!
147//! [`faults::Faults`] is a set. A fault can be armed by name (deterministic: it
148//! fires every time it can) or a **seed** can be handed in, in which case the
149//! fault weather for the whole run is derived from that seed alone. A failing
150//! run is replayable from its seed and nothing else — no recording, no capture
151//! file. See [`rng::SplitMix64`]: the generator is thirty lines and in this
152//! crate on purpose, because a dependency's PRNG may change its stream between
153//! versions and then a seed stops naming a run.
154//!
155//! # Behaviour 10 was wrong, and the correction is the more useful entry
156//!
157//! This crate shipped claiming the API sends no `created` on storage rows, so a
158//! young volume and an old orphan were the same row. That was the brief and the
159//! brief was mistaken. MEASURED against the live account 2026-09-20 — two
160//! volumes, both endpoints, `created` present every time:
161//!
162//! ```text
163//! 018a7b17-5698-4aa9-b160-f6a6daea9921
164//!   GET /1.3/storage/private  → "created": "2026-09-20T10:35:18Z"
165//!   GET /1.3/storage/{uuid}   → "created": "2026-09-20T10:35:18Z"
166//! the Resize Backup's detail  → "created": "2026-09-19T21:54:06Z"
167//! ```
168//!
169//! **A mock stricter than the provider is worse than one that is laxer.** This
170//! crate wrote that sentence itself, about the virtio hot-plug attach it used to
171//! refuse and which manufactured 82 % of a storm's failures. The same mistake,
172//! twice, in opposite directions: a lax mock misses a bug, a strict one invents
173//! a verdict path the account cannot reach. It is written here rather than
174//! quietly fixed because the correction is worth more than the entry was.
175//!
176//! What remains true is smaller and still worth having: the `Resize Backup`
177//! carries no product name in its title (behaviour 9), so a TITLE filter is
178//! blind to it — and that is what `labelled_gunnar` in `private-gunnar-ops`
179//! now exists for. The date was never the missing half; the label was.
180//!
181//! The storage rows the mock sends carry `created` because that is MEASURED.
182//! The server rows carry it too, which is INFERRED from the same API sending it
183//! for storages and has not been measured on its own — marked here rather than
184//! presented as fact, and erring toward sending it for the reason above.
185//!
186//! # What it is NOT
187//!
188//! It is not a simulator of UpCloud's billing, its IP transfer market, its
189//! network products (routers, gateways, load balancers, object storage), its
190//! managed databases or its Kubernetes. It answers the subset this estate
191//! drives, listed in [`http`], and answers `404` with a `NOT_IMPLEMENTED`
192//! envelope naming the path for everything else — loudly, so an unimplemented
193//! call can never read as a working one.
194
195pub mod clock;
196pub mod digest;
197pub mod estate;
198pub mod faults;
199pub mod guest_clock;
200pub mod http;
201pub mod net;
202pub mod render;
203pub mod tf;
204pub mod rng;
205pub mod self_check;
206pub mod kvm;
207pub mod face;
208
209pub use clock::{Clock, Timings};
210pub use estate::Estate;
211pub use faults::{Fault, Faults};
212pub use http::{serve, Mock};
213pub use self_check::{self_check, Report, Verdict};