Skip to main content

Crate mock_upcloud

Crate mock_upcloud 

Source
Expand description

mock-upcloud — the UpCloud API 1.3, faithfully wrong.

A real HTTP server that answers UpCloud’s 1.3 surface out of a state machine. Point UPCLOUD_API_BASE at it and the real monetize-cloud-impl upcloud plugin, the real gunnar/deploy/upcloud procedure and the real cargo xtask estate verbs talk to it without knowing. Nothing here reaches the network.

§The point is the lies, not the happy path

A mock that answers every call correctly proves nothing, because that is not the provider anybody has. Every behaviour below was MEASURED against the live account or is written down in this estate’s history (3, 5 and 37 are the exception: verified against Scaleway, not UpCloud — see below), and each one is armable on its own (faults::Fault) so a defect can be reproduced on demand instead of waited for.

#behaviourwhere it is modelled
1a 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
2GET /1.3/price fails at the TRANSPORT level, not with a statusFault::PriceTransportReset — the socket is closed mid-reply
3412 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
4the server LIST carries no storage_devices; the storage list DOES carry labelsrender, always on
5a 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
6create a server: 98–105 s[clock::Timings::server_create]
7delete front/twin ≈ 60 s; delete an appliance carrying 4 member volumes > 5 min in maintenance[clock::Timings::server_delete]
8the guest’s own install ≈ 10 s (INSTALL-OK 10 011 ms measured under KVM)[clock::Timings::guest_install] / the KVM half
9stop-resize-start leaves an auto-titled Resize Backup, detached, labelled, WITHOUT “gunnar” in the titleestate::Estate::modify_server
10young objects are indistinguishable from orphans without created WRONG, and corrected belowcreated is SENT, on both the list and the detail; Fault::WithholdCreatedField takes it away on request
11a server must be stopped (hard) before it can be deleted with its storagesestate::Estate::delete_server
12utility IPs are reassigned between re-lays; public IPs change every timeestate::Addresses, shuffled per lay from the seed
13no serial console — VNC only; a PID-1 panic is invisible except on the framebufferthe KVM half boots -serial none
14the VNC port goes stale on every stop/start and the API keeps reporting the old oneFault::StaleVncPort (default ON — it is the provider’s normal)
15SeaBIOS, no UEFIthe KVM half refuses an OVMF spec by name
16cdrom/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
17a re-image killed mid-install loops the installer (CD stays first in the boot order)[estate::Guest::boot]
18inbound UDP replies are dropped (DNS/NTP)Fault::UdpInboundDropped, default ON; guest_clock::ntp_answer answers silence
19a guest boots one TIMEZONE OFFSET ahead — +2 h in summer, +1 h in winter — and not every guest doesguest_clock: the hypervisor’s RTC is correct UTC, the GUEST misreads it (Fault::GuestReadsRtcAsLocalTime)
20the installer PID 1 brings no network up, so nothing narrates the install windowthe KVM half’s install image
21on first boot unattended-upgrade-shutdown --wait-for-signal holds dpkg lock-frontend for the life of the bootthe Ubuntu template guest
22a volume NEVER shrinks; a growth is permanent and billed foreverestate::Estate::modify_storage refuses a shrink by name
23the 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 moreestate::Estate::uploadestate::After::BeginSync
24the import object carries read_bytes/written_bytes and REAL md5sum/sha256sum — the ladder verifies the sha256 against the local filedigest, which computes them rather than inventing them
25the 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 waitingclock::Timings::storage_sync_lo_ms
26POST /storage/{uuid}/clone is the candidate fix, and whether it skips the sync is MEASURED after all — see 53estate::Estate::clone_storage; pessimistic by default, Fault::CloneSyncsLikeImport for the optimistic guess
27one wrong clock is SIX red rows: skew-refusal → no-quorum → clock-unchanged → console-key → banner → monetize-pollguest_clock::cascade
28the 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
29DHCP option 121 is offered and a guest that ignores it has NO ROUTE off its /22 — outbound dies, inbound stays healthynet::outbound_reach / Fault::GuestIgnoresDhcpOption121
30hairpin NAT does not exist: the box holding a DNAT cannot use it on its own public addressnet::Reach::NoHairpin — and it reads connection refused
31the console is a HOST and a port, and the toggle re-provisions BOTH (se-sto1.vnc.upcloud.com:60031)estate::Server::console, render::server
32the guest’s install is 2–5 s on the UART while the ladder’s wall measurement of the same re-image is 110–253 sclock::Timings::reimage_observers — two observers, two orders of magnitude
33a re-image mints a NEW SSH host key every time; three paths must agree to tell that from a hijacked nameestate::Estate::host_key_via / Fault::HijackedName
34the 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 allestate::Estate::resize_filesystem
35the 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)
36GET /1.3/server/{uuid} answers 404 SERVER_NOT_FOUND for a uuid the LIST carries — the account contradicting itselfFault::DetailNotFoundForListedServer, by name only (REPORTED 2026-09-20, uuids never compared before the box was wiped)
37a 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 faultestate::Estate::create_server / Fault::OutOfStock, one sticky fault for both doors
38a 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 oneestate::Estate::detach / Fault::DetachSaysSuccessButStaysAttached, by name only (REPORTED)
39an empty path segment (/1.3//storage) is a 404 that names no pathhttp, always on — gunnar api.rs, MEASURED
40a READ answers 502 now and then; a poll must survive itFault::ReadBadGateway, by name (REPORTED, gunnar wait.rs)
41PUT /1.3/server/{uuid} answers 202http, always on — MEASURED 2026-09-19
42a chunked PUT to the direct-upload URL answers 411http, always on (REPORTED, gunnar http.rs)
43a DEAD token is a clean 401 AUTHENTICATION_FAILED on every callFault::DeadToken — MEASURED 2026-09-14 (ROTATION §2.3)
44past the account’s MaxIOPS quota (yvra: 10 240 GiB) a create or grow is 403 MAXIOPS_STORAGE_LIMIT_REACHEDestate::Estate::create_storage / modify_storage, always on (limit MEASURED, code REPORTED)
45a disk boot reads started ~10 s after the start (was 20 s here)clock::Timings::start_ms — MEASURED 2026-09-19
46an installer that powers the box off never reads started: maintenance t+10…t+130 s, then stoppedclock::Timings::install_pass_lo_ms, estate::After::GuestPoweredOff — MEASURED 2026-09-15/19
47stop_server with timeout_action is a 400 (it belongs to restart)http, always on — MEASURED 2026-09-14
48a plan change is done when the PUT answers (the provider starts at once); a start during maintenance is 409estate::Estate::modify_server / start_server — the provider’s order MEASURED 2026-09-21
49a boot_order PUT does not end an install loop; it is read at the next HYPERVISOR startalways on, now tested — MEASURED 2026-09-14
50the VNC toggle settles ~2 s after the “no”; a “yes” inside the window gives the OLD endpoint backclock::Timings::vnc_settle_ms — MEASURED (gunnar console.rs)
51the console toggle is 409 while the server is maintenanceestate::Estate::modify_server — MEASURED (memory: upcloud-vnc-port-toggle)
52an import’s storage ends syncingmaintenanceonlineclock::Timings::sync_tail_maintenance_ms — sequence MEASURED, split not
53a clone is maintenanceonline 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
54a 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)
55a size grow is maintenance for 37 s, then onlineclock::Timings::storage_grow_ms — MEASURED 2026-09-14
56a 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)
57a storage already attached is 409 STORAGE_ATTACHED on a second attachestate::Estate::attach_at — REPORTED
58a 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}
59a storage in maintenance/syncing, or the source of a clone still being made, cannot be deleted: 409 STORAGE_STATE_ILLEGALestate::Estate::delete_storage — REPORTED
60virtio 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
61an interface that asks for IPv6 gets IPv6 (“no IPv6” is the estate’s law, not UpCloud’s)render::server — REPORTED
62the 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 dropnet::firewall_admits, /mock/inbound/{uuid}, /mock/udp-reply/{uuid} — MEASURED 2026-09-13/14/20
63the mock says whom it heard (per credential digest), so a terraform verb can prove the provider talked to THIS mock before apply/destroyhttp::Mock::heard_from, /mock/heard?token_sha256=
64a 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
65POST /server/{uuid}/cdrom/load puts a medium into an EMPTY tray; a loaded one is 409 CDROM_DEVICE_IN_USEhttp — L110, DOCUMENTED, not measured
66an import straight after POST /storage (no wait) is accepted; any other non-online state is still refusedestate::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
67the direct-upload PUT answers the import object BARE ({"written_bytes": …, "sha256sum": …}), not wrapped in storage_importhttp — MEASURED: the live re-image of 2026-09-21 08:42 read it at the top level, done: true
68the 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 answerhttp — ledger L112

Rows 39–63 are lane T13’s, from BEHAVIOURS-LEDGER.md beside this crate, where every one carries its source. Three rows above are not UpCloud’s: 3 and 37 (412 out_of_stock) and 5 (“0 rows + 403”) were verified against Scaleway (fr-par-1 2026-09-09; the IAM change 2026-08-26), not UpCloud. Owner ruling 2026-09-21: they are kept as generic cloud-provider faults — relabelled, not removed, because removing them would re-map every storm seed — and they are never to be read as UpCloud behaviour.

Behaviours 35 and 36 exist for ONE caller: private-gunnar-ops’s estate::prune_dead_firewalls and the row F38 that proves it. Together with 1 and 5 they are the four ways a 403/404 pair can lie about whether a server is there, and a caller that reads the list, the detail AND the firewall before it drops a state entry is the only caller that survives all four. A stale state naming a server that was re-laid under a NEW uuid — the likeliest reading of 2026-09-20 — needs no fault at all: create the estate, ask about a uuid that was never created, and the mock answers 404 on the server and 403 on its firewall while the list shows the same titles alive.

§Two callers, one API

Everything above is the surface monetize-cloud-impl’s plugin and gunnar/deploy/upcloud drive. private-holger-ops drives the SAME account through terraform and UpCloudLtd/upcloud 5.44, which carries UPCLOUD_DEBUG_API_BASE_URL — so the real declaration and the real provider can be pointed here too, and holger’s twin, which has never been created because creating it costs money, can be laid down for nothing. That door is tf, and the first thing it proved was about this crate rather than about holger: a device’s storage_size was rendered as a STRING here, the official provider’s own SDK declares it int, and every terraform server create died on a shape the account cannot send. Behaviour 10’s lesson, a second time.

§The calibration

Every fault above is a claim that the mock can be wrong in a particular way, and a claim nobody has checked is a claim. [self_check] drives EVERY variant in Fault::ALL twice — disarmed and armed — and fails if the two observations are the same, because a fault that changes nothing a caller can see advertises a coverage this crate does not have. mock-upcloud --self-check prints the table and exits non-zero. Behaviour 38 is what it is for: Estate::detach had no path that answered 200 without removing the device, so every sweep driven against this mock passed a test the mock could not fail.

§Time

Every duration above is real, and running 100 000 of them at real speed is three years. So the mock keeps a virtual clock (clock::Clock) with a speed knob: 1.0 is faithful wall time, 0.0 makes every transition instant, 100.0 is a hundred times quicker. The ORDER and the RELATIVE durations never change — a storage still goes maintenancemaintenanceonline, a delete still traverses maintenance, and a client that does not poll still fails. What changes is only how long the harness waits.

This is the reason the mock is a state machine over a clock rather than a table of canned replies: a canned reply cannot be early.

§Faults

faults::Faults is a set. A fault can be armed by name (deterministic: it fires every time it can) or a seed can be handed in, in which case the fault weather for the whole run is derived from that seed alone. A failing run is replayable from its seed and nothing else — no recording, no capture file. See rng::SplitMix64: the generator is thirty lines and in this crate on purpose, because a dependency’s PRNG may change its stream between versions and then a seed stops naming a run.

§Behaviour 10 was wrong, and the correction is the more useful entry

This crate shipped claiming the API sends no created on storage rows, so a young volume and an old orphan were the same row. That was the brief and the brief was mistaken. MEASURED against the live account 2026-09-20 — two volumes, both endpoints, created present every time:

018a7b17-5698-4aa9-b160-f6a6daea9921
  GET /1.3/storage/private  → "created": "2026-09-20T10:35:18Z"
  GET /1.3/storage/{uuid}   → "created": "2026-09-20T10:35:18Z"
the Resize Backup's detail  → "created": "2026-09-19T21:54:06Z"

A mock stricter than the provider is worse than one that is laxer. This crate wrote that sentence itself, about the virtio hot-plug attach it used to refuse and which manufactured 82 % of a storm’s failures. The same mistake, twice, in opposite directions: a lax mock misses a bug, a strict one invents a verdict path the account cannot reach. It is written here rather than quietly fixed because the correction is worth more than the entry was.

What remains true is smaller and still worth having: the Resize Backup carries no product name in its title (behaviour 9), so a TITLE filter is blind to it — and that is what labelled_gunnar in private-gunnar-ops now exists for. The date was never the missing half; the label was.

The storage rows the mock sends carry created because that is MEASURED. The server rows carry it too, which is INFERRED from the same API sending it for storages and has not been measured on its own — marked here rather than presented as fact, and erring toward sending it for the reason above.

§What it is NOT

It is not a simulator of UpCloud’s billing, its IP transfer market, its network products (routers, gateways, load balancers, object storage), its managed databases or its Kubernetes. It answers the subset this estate drives, listed in http, and answers 404 with a NOT_IMPLEMENTED envelope naming the path for everything else — loudly, so an unimplemented call can never read as a working one.

Re-exports§

pub use clock::Clock;
pub use clock::Timings;
pub use estate::Estate;
pub use faults::Fault;
pub use faults::Faults;
pub use http::serve;
pub use http::Mock;
pub use self_check::self_check;
pub use self_check::Report;
pub use self_check::Verdict;

Modules§

clock
Virtual time, and the measured durations it plays back.
digest
SHA-256 and MD5, here, because the checksums are load-bearing.
estate
The state machine: what the account holds, and how it moves.
face
FakeUpCloud — the mock’s in-process face on upcloud_api::UpCloudApi.
faults
The fault surface: every measured lie, armable on its own.
guest_clock
The clock, which is the provider’s most expensive lie — except that it is not the provider’s lie at all.
http
The socket: HTTP/1.1, hand-rolled, because one of the behaviours is not a status code.
kvm
The KVM half: the seam, and what a real guest has to do to fill it.
net
The network, and the two ways it is asymmetric.
render
The wire shapes — including the inconsistencies, which are the point.
rng
The one generator, so a seed names a run forever.
self_check
The calibration: prove the instrument can report the opposite, for every fault it claims to have.
tf
The terraform door — the same API 1.3, asked for by a different caller.