Skip to main content

mock_upcloud/
faults.rs

1//! **The fault surface: every measured lie, armable on its own.**
2//!
3//! Two ways to arm. By NAME, in which case the fault fires every time it can —
4//! that is the mode a regression test uses, because a test that only sometimes
5//! provokes the defect is a test that only sometimes passes. Or from a SEED, in
6//! which case the whole run's weather is derived from that one number: which
7//! faults are armed, at what rate, and for how long. A storm prints the seed of
8//! every distinct failure, and the seed alone replays it.
9//!
10//! # Sticky faults
11//!
12//! [`Fault::OutOfStock`] and [`Fault::RevokedCredential`] are STICKY: once they
13//! fire they keep firing until disarmed. That is what was measured — on
14//! SCALEWAY, not UpCloud (both are kept as generic cloud-provider faults):
15//! Scaleway's `fr-par-1` refused `poweron` with `412 out_of_stock` for days,
16//! and a revoked Scaleway key answered 0 servers from the moment it was revoked. A fault that fires
17//! once and clears would let a retry loop paper over both, which is precisely
18//! the bug that shipped.
19
20use crate::rng::SplitMix64;
21use std::collections::BTreeSet;
22use std::sync::Mutex;
23
24/// One measured behaviour, armable.
25///
26/// The variants are the mock's whole vocabulary of wrongness: if a defect cannot
27/// be named here it cannot be provoked, and the answer is a new variant, not a
28/// flag on a handler.
29#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
30pub enum Fault {
31    /// **Behaviour 2.** `GET /1.3/price` drops the socket instead of answering.
32    /// Not a status: the connection is closed with no reply at all, which is
33    /// what made monetize's start-time probe report "the credential in
34    /// UPCLOUD_TOKEN could not be verified" for what was a transport failure.
35    /// A mock that answered 503 here would never have shown that.
36    PriceTransportReset,
37    /// **Behaviours 3 and 37.** `412 out_of_stock`, at BOTH doors that need
38    /// capacity: `POST /1.3/server/{uuid}/start` (behaviour 3) and
39    /// `POST /1.3/server` (behaviour 37). Sticky.
40    ///
41    /// One variant, not two, because what is out of stock is a PLAN IN A ZONE
42    /// and both calls ask for one. A mock that could be sold out at the
43    /// poweron and in stock at the create would let a caller pass by knocking
44    /// on the luckier door.
45    ///
46    /// **Verified against Scaleway, not UpCloud; kept as a generic
47    /// cloud-provider fault** (ledger X17, owner ruling 2026-09-21). The only
48    /// dated measurement of "412 out_of_stock on poweron, for days" is
49    /// Scaleway's fr-par-1 from 2026-09-09. UpCloud documents its capacity
50    /// refusal as `409 STORAGE_RESOURCES_UNAVAILABLE`. Relabelled, not removed:
51    /// it stays in the seeded weather, because removing it re-maps every seed.
52    OutOfStock,
53    /// **Behaviour 5.** The credential is revoked: lists answer 200 with ZERO
54    /// rows, details answer 403. Never a clean 401 anywhere. Sticky.
55    ///
56    /// **Verified against Scaleway, not UpCloud; kept as a generic
57    /// cloud-provider fault** (ledger X17, owner ruling 2026-09-21). The only
58    /// dated measurement of this shape (2026-08-26) is the Scaleway IAM change
59    /// ("0 servers, 403 on VPC"). A dead UpCloud token is MEASURED as a clean
60    /// 401: that is [`Fault::DeadToken`]. Relabelled, not removed: it stays in
61    /// the seeded weather, because removing it re-maps every seed.
62    RevokedCredential,
63    /// **Behaviour 14.** The VNC port reported after a stop/start is the one
64    /// from before it, and stays wrong until `remote_access_enabled` is toggled
65    /// no→yes. Default ON: it is not an exceptional condition, it is what the
66    /// API does.
67    StaleVncPort,
68    /// **Behaviour 17.** A re-image interrupted mid-install leaves the CD first
69    /// in the boot order, so the next boot runs the installer again, forever.
70    InstallerLoop,
71    /// **A HYPOTHESIS, not a measurement: withhold `created`.**
72    ///
73    /// This crate had it the other way round and had it WRONG. The header used
74    /// to claim the API sends no `created` on these rows, so a young volume and
75    /// a six-month-old orphan were the same row and only a title could tell
76    /// them apart. That was the brief, and the brief was mistaken.
77    ///
78    /// MEASURED against the live account 2026-09-20, two volumes, both
79    /// endpoints, `created` every time:
80    ///
81    /// ```text
82    /// 018a7b17-5698-4aa9-b160-f6a6daea9921
83    ///   GET /1.3/storage/private  → "created": "2026-09-20T10:35:18Z"
84    ///   GET /1.3/storage/{uuid}   → "created": "2026-09-20T10:35:18Z"
85    /// the Resize Backup's detail  → "created": "2026-09-19T21:54:06Z"
86    /// ```
87    ///
88    /// So the field is sent, the mock now sends it, and this fault only takes
89    /// it away on request. It is the same failure this crate has already made
90    /// once, in its own words: a mock stricter than the provider is worse than
91    /// one that is laxer, because a lax mock misses a bug and a strict one
92    /// INVENTS them — the attach refusal manufactured four hundred failures in
93    /// one storm, and this manufactured a verdict path the account cannot
94    /// produce.
95    ///
96    /// Kept, at rate 0 and never in seeded weather, for the same reason
97    /// [`Fault::CloneSyncsLikeImport`] is kept: the disagreement it provokes between
98    /// `classify` and `refine` in `private-gunnar-ops` is real and worth a
99    /// regression test, and a hypothesis must be asked for BY NAME rather than
100    /// arrive as weather.
101    WithholdCreatedField,
102    /// A 503 on any write, the ordinary provider wobble. The one fault that is
103    /// not interesting on its own and is here so the retry paths are exercised.
104    WriteUnavailable,
105    /// **Behaviour 9, sharpened.** The `Resize Backup` a stop-resize-start
106    /// leaves behind points its `origin` at a volume that is ALREADY GONE, so a
107    /// cleanup that resolves the origin gets `STORAGE_NOT_FOUND` rather than a
108    /// parent.
109    OrphanResizeBackup,
110    /// **A HYPOTHESIS, by name: the `Resize Backup` carries NO labels.**
111    ///
112    /// MEASURED 2026-09-20 on the live account, the backup a twin resize left
113    /// behind DID carry the origin volume's labels (`site=gunnar.rs`,
114    /// `repo=private-gunnar-ops`, `role=twin`, `volume=twin`), which is what
115    /// the mock copies by default. The owner's brief for F39 says the opposite
116    /// — an object UpCloud titled itself carries NEITHER a product title NOR
117    /// the estate's labels — and whether the provider copies labels on every
118    /// backup kind, or only on the one that was measured, is not known.
119    ///
120    /// So both are reachable, and the sweep is proven against both: the default
121    /// is the measurement, and this fault is the brief. Rate 0, never in seeded
122    /// weather, asked for by name — the rule [`Fault::WithholdCreatedField`]
123    /// already follows, for the same reason: a mock stricter than the provider
124    /// invents verdict paths, so a hypothesis must announce itself.
125    ///
126    /// What a sweep has left when both the title and the labels are silent is
127    /// the backup's `origin`, which names the volume it was taken from. That is
128    /// the recognition path this fault exists to exercise.
129    ResizeBackupUnlabelled,
130    /// **The import that does not finish.** The upload completes, every byte is
131    /// accounted for — and then the import object goes `failed` with an
132    /// `error_code`/`error_message` pair and the storage lands in `error`
133    /// instead of `online`. The caller's poll loop has to tell that apart from
134    /// `syncing`, and a loop that only looks for `online` waits its whole budget
135    /// for a volume that is never coming.
136    ImportFailed,
137    /// **The sync that outlasts the budget.** The caller polls a direct upload
138    /// with a 1200 s budget; this makes the `syncing` wait 1300 s. A timeout
139    /// that has never fired is a timeout nobody has read the handler of.
140    SyncExceedsBudget,
141    /// **The OLD pessimistic guess about a clone, kept by name only.** A clone
142    /// waits in `syncing` for 100–130 s, exactly like an import. This was the
143    /// mock's DEFAULT until lane T13 found the measurement it said did not
144    /// exist: gunnar `deploy/upcloud/tests/clone_probe.rs`, 2026-09-20 — 728 ms
145    /// call, `maintenance` → `online` in 47 s, NO `syncing`. The default is now
146    /// the measurement; this is the guess, at rate 0 (ledger X3). It replaces
147    /// `clone-skips-sync`, whose optimistic "guess" is now simply the default,
148    /// and that name no longer parses — loudly, on purpose.
149    CloneSyncsLikeImport,
150    /// **The guest reads the hypervisor's UTC clock as local time.** The
151    /// hypervisor is right; the GUEST is wrong, because it runs gunnar as PID 1
152    /// with no `systemd-timedated` and no `/etc/adjtime` to establish what the
153    /// RTC holds. Measured: the appliance was +7 198 668 ms from the front
154    /// while the front, on the same hypervisor in the same zone, was +468 ms.
155    ///
156    /// Armed per-server through [`crate::estate::Estate::create_server`], never
157    /// applied to the clock itself — a mock that skewed its own clock would
158    /// model the symptom and let a fix that subtracts two hours somewhere pass.
159    GuestReadsRtcAsLocalTime,
160    /// **Inbound UDP replies are dropped.** DEFAULT ON, because it is the
161    /// provider's normal: NTP and DNS-over-UDP simply never answer, which is
162    /// why `systemd-timesyncd` is useless up there and why `gunnar-clock` takes
163    /// signed time from the front over TCP and from nowhere else. A mock that
164    /// let UDP through would let a fix that "just uses NTP" look correct here
165    /// and fail there.
166    UdpInboundDropped,
167    /// **The guest ignores DHCP option 121 and has no route off its own /22.**
168    /// It comes up with a good address, answers everything sent TO it, and
169    /// cannot reach the front at all — so its clock sync and its boot narration
170    /// die OUTBOUND while every inbound probe says the box is healthy. It reads
171    /// exactly like a two-hour clock bug and is not one. Measured; fixed in
172    /// gunnar `35ac0c3`.
173    ///
174    /// Armed per-server at create, like the RTC interpretation: it is a fact
175    /// about the image, not a coin flipped per packet.
176    GuestIgnoresDhcpOption121,
177    /// **A different machine is answering on the name.** The `name` path's SSH
178    /// host key stops matching the `front` and `direct` paths'. This is the
179    /// case the three-path check exists for — a re-image mints a new key every
180    /// time, so `REMOTE HOST IDENTIFICATION HAS CHANGED` cannot be treated as
181    /// alarming on its own, and the only thing that separates a new machine
182    /// from a stolen name is three paths agreeing.
183    HijackedName,
184    /// A create that succeeds at the provider and times out at the client: the
185    /// reply is dropped after the object is committed. The idempotency seam's
186    /// reason to exist — a plugin that retries on this and does not search by
187    /// label buys twice.
188    CommitThenDropReply,
189    /// **Behaviour 35 — the firewall of a server that READS FINE answers 403.**
190    ///
191    /// Behaviour 1 is the firewall endpoint answering `403
192    /// ERROR_AUTHENTICATION_FAILED` for a server that is GONE. This is the same
193    /// body, byte for byte, for a server that is alive and answers its own
194    /// `GET /1.3/server/{uuid}` with 200 — the shape of a credential scoped
195    /// without the firewall permission. The two are indistinguishable AT THE
196    /// FIREWALL ENDPOINT; only asking the server itself tells them apart, which
197    /// is the whole point of a caller that reads both before it decides.
198    ///
199    /// INFERRED from the provider's permission-scoped API tokens, not measured
200    /// on this estate's account: on 2026-09-20 every 403 seen at this endpoint
201    /// sat beside a 404 on the server, which is behaviour 1. Rate 0 — a shape
202    /// that has not been measured is asked for BY NAME and never arrives as
203    /// weather. Sticky, like every other credential property.
204    FirewallForbidden,
205    /// **Behaviour 38 — the detach answers `200` and the volume is STILL
206    /// ATTACHED.**
207    ///
208    /// The write that reports its own success and did not happen. Not a
209    /// refusal, not a timeout, not a slow poll: a clean `200` with the server
210    /// object in the reply, and the device still on `GET /1.3/server/{uuid}`
211    /// afterwards — so a caller that trusts the status and does not READ BACK
212    /// goes on to delete a volume that is attached, or leaves a growth half
213    /// done believing it finished.
214    ///
215    /// The mock could not express this at all before: `Estate::detach` had four
216    /// outcomes, three refusals and a removal, and no path that answered `200`
217    /// without removing the device. So a sweep or a growth written against this
218    /// mock could never meet the shape, and every green it printed was a green
219    /// about a mock that could only tell the truth. This is the whole argument
220    /// for the fault surface — a defect that cannot be named here cannot be
221    /// provoked, and the answer is a variant.
222    ///
223    /// **Not sticky**, because it is a property of ONE WRITE and not of the
224    /// account or the zone: stickiness here would mean no detach could ever
225    /// succeed again for the run's lifetime, which hides the read-back-and-
226    /// retry path this fault exists to exercise (contrast
227    /// [`Fault::OutOfStock`], where the shortage really does outlive the call).
228    ///
229    /// REPORTED, not measured on this account: rate 0, asked for by name, the
230    /// same rule [`Fault::WithholdCreatedField`] and [`Fault::CloneSyncsLikeImport`]
231    /// already follow.
232    DetachSaysSuccessButStaysAttached,
233    /// **Behaviour 36 — `GET /1.3/server/{uuid}` answers 404 for a uuid the
234    /// LIST carries.** The account contradicting itself: the list says the
235    /// server exists, its own detail says `SERVER_NOT_FOUND`.
236    ///
237    /// REPORTED 2026-09-20 on t14s: a `plan` against the live account read the
238    /// state's server uuid as 404 while `GET /1.3/server` showed gunnar-front,
239    /// gunnar-appliance, gunnar-twin, holger-front and njord. Whether the uuid
240    /// the list carried was the SAME uuid the state named was not captured
241    /// before that box was wiped — so this is a report and not a measurement,
242    /// and the more likely reading (a stale state naming a server that was
243    /// re-laid under a new uuid) needs no fault at all: it is the ordinary
244    /// state machine with a uuid that was never created. Rate 0, by name only,
245    /// kept so the caller's "the account contradicts itself" refusal has a path
246    /// that reaches it.
247    DetailNotFoundForListedServer,
248    /// **Behaviour 43 — a DEAD token is a clean `401 AUTHENTICATION_FAILED`,
249    /// everywhere.** MEASURED 2026-09-14 (private-gunnar-ops ROTATION §2.3:
250    /// `UPCLOUD_TOKEN_HENTOR` and `MONETIZE_UPCLOUD_TOKEN`, both 401). The
251    /// "0 rows and 403" shape of [`Fault::RevokedCredential`] is the one whose
252    /// only dated measurement is Scaleway's (ledger X17); this is UpCloud's.
253    /// Sticky, like every property of a credential. Rate 0: new faults stay out
254    /// of the seeded weather so existing seeds keep naming the same runs.
255    DeadToken,
256    /// **Behaviour 40 — a READ answers `502`.** REPORTED (gunnar `wait.rs`: "the
257    /// API occasionally returns 502, so a failed poll is not fatal"). The mock
258    /// only had 503-on-write; a poll loop that dies on its first 502 was never
259    /// exercised. Rate 0 (see [`Fault::DeadToken`]).
260    ReadBadGateway,
261    /// **Behaviour 60 — the guest's kernel cannot hot-plug PCI.** Decided per
262    /// server at create, like the RTC reading: it is a property of the image.
263    /// Such a guest never acks the ACPI `_EJ0`, so a virtio attach or detach on
264    /// a STARTED server answers **`511 HOTPLUG_FAILED`** — MEASURED 2026-09-14
265    /// on the live appliance (tunnr 6.12.104, no `HOTPLUG_PCI`). A hot-plug
266    /// kernel needs `HOTPLUG_PCI(_ACPI/_PCIE/_SHPC)`, `PCIEPORTBUS` and
267    /// **`PCI_MSI=y`** (without MSI `_OSC` refuses OS control; measured under
268    /// KVM). Disarmed, the guest can hot-plug and the virtio call succeeds,
269    /// which is what the Ubuntu template and tunnr ≥ 80c3233 do. Rate 0.
270    GuestKernelLacksHotplug,
271    /// **Behaviour 56, the stricter reading — a size grow of ANY attached
272    /// storage is refused `409 STORAGE_ATTACHED`, "must first be detached".**
273    /// UpCloud's docs contradict themselves (DATA-SET-GROWTH-DESIGN §2): one
274    /// page says the server must be `stopped`, the other that the storage must
275    /// be detached. Both refuse a grow under a RUNNING server, which the mock
276    /// now always does; this fault is the page that also refuses it under a
277    /// stopped one. Not measured either way: rate 0, by name.
278    ResizeRequiresDetach,
279    /// **Behaviour 69 — the REBOOTING medium** (ledger L113): a medium composed
280    /// WITHOUT `install_then=poweroff` (every korp-installer < 0.1.4; the
281    /// medium of the 2026-09-14 loop). Its installer start reads **`started`**
282    /// — MEASURED 2026-09-14: `cdrom/eject` answered 200 on a STARTED box
283    /// mid-pass — for the whole pass, 900–1100 s (gunnar plan.rs: every
284    /// measured pass ≥ 900 s; 1000–1100 s live on 2026-09-08). At its end the
285    /// guest REBOOTS: with the CD still loaded and first it installs again
286    /// (behaviour 17), otherwise it boots the disk. Disarmed, the medium is the
287    /// power-off kind (behaviour 46). A property of the MEDIUM, so asked for by
288    /// name: rate 0.
289    InstallerReboots,
290}
291
292impl Fault {
293    pub const ALL: &'static [Fault] = &[
294        Fault::PriceTransportReset,
295        Fault::OutOfStock,
296        Fault::RevokedCredential,
297        Fault::StaleVncPort,
298        Fault::InstallerLoop,
299        Fault::WithholdCreatedField,
300        Fault::WriteUnavailable,
301        Fault::OrphanResizeBackup,
302        Fault::ResizeBackupUnlabelled,
303        Fault::CommitThenDropReply,
304        Fault::ImportFailed,
305        Fault::SyncExceedsBudget,
306        Fault::CloneSyncsLikeImport,
307        Fault::GuestReadsRtcAsLocalTime,
308        Fault::UdpInboundDropped,
309        Fault::GuestIgnoresDhcpOption121,
310        Fault::HijackedName,
311        Fault::FirewallForbidden,
312        Fault::DetailNotFoundForListedServer,
313        Fault::DetachSaysSuccessButStaysAttached,
314        Fault::DeadToken,
315        Fault::ReadBadGateway,
316        Fault::GuestKernelLacksHotplug,
317        Fault::ResizeRequiresDetach,
318        Fault::InstallerReboots,
319    ];
320
321    pub fn name(self) -> &'static str {
322        match self {
323            Fault::PriceTransportReset => "price-transport-reset",
324            Fault::OutOfStock => "out-of-stock",
325            Fault::RevokedCredential => "revoked-credential",
326            Fault::StaleVncPort => "stale-vnc-port",
327            Fault::InstallerLoop => "installer-loop",
328            Fault::WithholdCreatedField => "withhold-created-field",
329            Fault::WriteUnavailable => "write-unavailable",
330            Fault::OrphanResizeBackup => "orphan-resize-backup",
331            Fault::ResizeBackupUnlabelled => "resize-backup-unlabelled",
332            Fault::CommitThenDropReply => "commit-then-drop-reply",
333            Fault::ImportFailed => "import-failed",
334            Fault::SyncExceedsBudget => "sync-exceeds-budget",
335            Fault::CloneSyncsLikeImport => "clone-syncs-like-import",
336            Fault::GuestReadsRtcAsLocalTime => "guest-reads-rtc-as-local-time",
337            Fault::UdpInboundDropped => "udp-inbound-dropped",
338            Fault::GuestIgnoresDhcpOption121 => "guest-ignores-dhcp-option-121",
339            Fault::HijackedName => "hijacked-name",
340            Fault::FirewallForbidden => "firewall-forbidden",
341            Fault::DetailNotFoundForListedServer => "detail-404-for-listed-server",
342            Fault::DetachSaysSuccessButStaysAttached => "detach-says-success",
343            Fault::DeadToken => "dead-token",
344            Fault::ReadBadGateway => "read-bad-gateway",
345            Fault::GuestKernelLacksHotplug => "guest-kernel-lacks-hotplug",
346            Fault::ResizeRequiresDetach => "resize-requires-detach",
347            Fault::InstallerReboots => "installer-reboots",
348        }
349    }
350
351    /// **The one line `--help` prints for this fault.**
352    ///
353    /// It lives here, beside the variant, and the match is EXHAUSTIVE: a new
354    /// variant that forgets its line does not compile. That is the whole
355    /// mechanism. The help text used to be a hand-written list in the binary
356    /// and it went stale exactly as hand-written lists do — it advertised a
357    /// fault called `grant-created-field` that has never existed under that
358    /// name (the real one is `withhold-created-field`, and it does the
359    /// OPPOSITE), and it named 9 of the 16 faults there were. A person who
360    /// read it and typed what it said got `no such fault`.
361    pub fn summary(self) -> &'static str {
362        match self {
363            Fault::PriceTransportReset => "GET /1.3/price drops the socket — a transport failure, not a status",
364            Fault::OutOfStock => "412 out_of_stock at BOTH doors: the create and the poweron",
365            Fault::RevokedCredential => "0 rows on lists, 403 on details; never a clean 401",
366            Fault::StaleVncPort => "the console reported after a stop/start is the one from before it",
367            Fault::InstallerLoop => "the CD stays first in the boot order, so the installer runs again",
368            Fault::WithholdCreatedField => "the rows carry no `created` (a HYPOTHESIS; the account does send it)",
369            Fault::WriteUnavailable => "503 on writes, the ordinary provider wobble",
370            Fault::OrphanResizeBackup => "the Resize Backup's `origin` names a volume that is already gone",
371            Fault::ResizeBackupUnlabelled => "the Resize Backup carries NO labels (a HYPOTHESIS; the measurement says it does)",
372            Fault::CommitThenDropReply => "the object is created and the reply never arrives",
373            Fault::ImportFailed => "every byte arrives and the import then goes `failed`, the storage `error`",
374            Fault::SyncExceedsBudget => "the post-upload `syncing` outlasts the caller's 1200 s budget",
375            Fault::CloneSyncsLikeImport => "a clone waits in `syncing` like an import (the OLD guess; measured: 47 s, no sync)",
376            Fault::GuestReadsRtcAsLocalTime => "the guest reads the hypervisor's UTC as local time — two hours, decided at create",
377            Fault::UdpInboundDropped => "inbound UDP replies never come back, so NTP and DNS-over-UDP are silent",
378            Fault::GuestIgnoresDhcpOption121 => "the guest takes the address and not the routes: outbound dies, inbound looks healthy",
379            Fault::HijackedName => "a different machine answers the host key on the NAME path",
380            Fault::FirewallForbidden => "a LIVE server's firewall_rule answers 403, the same body a deleted one's does",
381            Fault::DetailNotFoundForListedServer => "GET /1.3/server/{uuid} answers 404 for a uuid the LIST carries",
382            Fault::DetachSaysSuccessButStaysAttached => "the detach answers 200 and the volume stays attached",
383            Fault::DeadToken => "a dead token: a clean 401 AUTHENTICATION_FAILED on every call",
384            Fault::ReadBadGateway => "a read answers 502, the gateway wobble a poll must survive",
385            Fault::GuestKernelLacksHotplug => "the guest cannot hot-plug: a virtio attach/detach on a running server is 511 HOTPLUG_FAILED",
386            Fault::ResizeRequiresDetach => "a grow of ANY attached storage is 409 STORAGE_ATTACHED (the stricter docs page)",
387            Fault::InstallerReboots => "the medium REBOOTS after its install: `started` for the 900-1100 s pass, then the CD again or the disk",
388        }
389    }
390
391    pub fn parse(s: &str) -> Option<Fault> {
392        Fault::ALL.iter().copied().find(|f| f.name() == s)
393    }
394
395    /// Sticky faults keep firing once they have fired.
396    pub fn sticky(self) -> bool {
397        matches!(self, Fault::OutOfStock | Fault::RevokedCredential | Fault::FirewallForbidden | Fault::DeadToken)
398    }
399
400    /// The rate, in parts per thousand, this fault fires at in seeded weather.
401    /// These are not guesses dressed as measurements — they are the rates that
402    /// make a 100 000-run storm produce every signature a few hundred times,
403    /// which is the only property a storm's rates need. The MEASURED rate of
404    /// `out_of_stock` at the provider was, for several days, 1000.
405    pub fn seeded_rate_per_mille(self) -> u64 {
406        match self {
407            Fault::PriceTransportReset => 20,
408            Fault::OutOfStock => 8,
409            Fault::RevokedCredential => 2,
410            // Not a fault so much as the weather: on by default everywhere.
411            Fault::StaleVncPort => 1000,
412            Fault::InstallerLoop => 15,
413            // Never in seeded weather: it is a HYPOTHESIS about the provider,
414            // contradicted by measurement, kept only so the verdict paths that
415            // would depend on an absent field stay testable.
416            Fault::WithholdCreatedField => 0,
417            Fault::WriteUnavailable => 30,
418            Fault::OrphanResizeBackup => 250,
419            // Never in seeded weather: the measurement says the labels ARE copied,
420            // and this is the brief that says they are not. Asked for by name.
421            Fault::ResizeBackupUnlabelled => 0,
422            Fault::CommitThenDropReply => 12,
423            Fault::ImportFailed => 10,
424            Fault::SyncExceedsBudget => 5,
425            // Never in seeded weather: it is a GUESS about the provider, not a
426            // fault of it, and a guess must be asked for by name.
427            Fault::CloneSyncsLikeImport => 0,
428            // An appliance gets it and a front does not, and which one is being
429            // created is not something a rate can decide — so it fires whenever
430            // it is armed, and the caller arms it for the guest that has the
431            // shape. In seeded weather it is on for a quarter of the runs,
432            // which is roughly the share of created servers that are
433            // appliances rather than fronts or twins.
434            Fault::GuestReadsRtcAsLocalTime => 250,
435            // Not a fault so much as the weather, like the stale VNC port.
436            Fault::UdpInboundDropped => 1000,
437            // Like the RTC misreading: a property of the image a server was
438            // made from, so roughly the share of created servers that are
439            // appliances.
440            Fault::GuestIgnoresDhcpOption121 => 250,
441            // Rare, and it must be: a name that answers a different key is an
442            // attack, not weather. It is in seeded weather at all only so the
443            // three-path check is exercised without being asked for.
444            Fault::HijackedName => 3,
445            // Both never in seeded weather. One is INFERRED from the provider's
446            // permission scopes and one is REPORTED off a box that was wiped
447            // before the uuids could be compared; neither is a measurement, and
448            // a shape that has not been measured is asked for by name.
449            Fault::FirewallForbidden => 0,
450            Fault::DetailNotFoundForListedServer => 0,
451            // REPORTED, not measured here. A write that lies about its own
452            // success is the kind of shape a storm should not hand out as
453            // weather until one real detach has been caught doing it.
454            Fault::DetachSaysSuccessButStaysAttached => 0,
455            // Lane T13's four: all by name. A new weather fault would add a
456            // draw to every call it is checked on and re-map every existing seed.
457            Fault::DeadToken => 0,
458            Fault::ReadBadGateway => 0,
459            Fault::GuestKernelLacksHotplug => 0,
460            Fault::ResizeRequiresDetach => 0,
461            // A property of the MEDIUM a run composed, not weather: by name.
462            Fault::InstallerReboots => 0,
463        }
464    }
465}
466
467/// The armed set plus the run's generator.
468pub struct Faults {
469    armed: Mutex<BTreeSet<Fault>>,
470    /// Faults that fire probabilistically rather than always, and the stream
471    /// that decides. `None` = no seeded weather: armed means always.
472    weather: Option<Mutex<SplitMix64>>,
473    seed: u64,
474    fired: Mutex<BTreeSet<Fault>>,
475}
476
477impl Faults {
478    /// Nothing armed but the weather that is always true: [`Fault::StaleVncPort`]
479    /// is the provider's normal behaviour, not an exception, and a mock that
480    /// hid it by default would leave the toggle cure untested.
481    pub fn none() -> Faults {
482        let mut armed = BTreeSet::new();
483        armed.insert(Fault::StaleVncPort);
484        // Inbound UDP does not come back. That is not an exceptional condition
485        // up there, it is Tuesday — and a mock that hid it by default would let
486        // every "just use NTP" fix look correct.
487        armed.insert(Fault::UdpInboundDropped);
488        Faults {
489            armed: Mutex::new(armed),
490            weather: None,
491            seed: 0,
492            fired: Mutex::new(BTreeSet::new()),
493        }
494    }
495
496    /// Nothing at all, not even the provider's normal. For the one test that
497    /// asserts the mock can be quiet.
498    pub fn quiet() -> Faults {
499        Faults {
500            armed: Mutex::new(BTreeSet::new()),
501            weather: None,
502            seed: 0,
503            fired: Mutex::new(BTreeSet::new()),
504        }
505    }
506
507    /// Weather derived from one number. Every fault whose
508    /// [`Fault::seeded_rate_per_mille`] is non-zero is armed, and fires at that
509    /// rate off a stream derived from the seed and the fault's own name — so
510    /// adding a tenth fault does not renumber the other nine's decisions, and a
511    /// seed printed today still replays tomorrow.
512    pub fn seeded(seed: u64) -> Faults {
513        let armed = Fault::ALL
514            .iter()
515            .copied()
516            .filter(|f| f.seeded_rate_per_mille() > 0)
517            .collect();
518        Faults {
519            armed: Mutex::new(armed),
520            weather: Some(Mutex::new(SplitMix64::new(seed))),
521            seed,
522            fired: Mutex::new(BTreeSet::new()),
523        }
524    }
525
526    pub fn seed(&self) -> u64 {
527        self.seed
528    }
529
530    pub fn arm(&self, f: Fault) {
531        self.armed.lock().unwrap().insert(f);
532    }
533
534    pub fn disarm(&self, f: Fault) {
535        self.armed.lock().unwrap().remove(&f);
536        self.fired.lock().unwrap().remove(&f);
537    }
538
539    pub fn is_armed(&self, f: Fault) -> bool {
540        self.armed.lock().unwrap().contains(&f)
541    }
542
543    /// Ask whether `f` fires NOW. Deterministic when armed by name; seeded when
544    /// the run has weather; sticky faults answer `true` forever once they have
545    /// answered it once.
546    pub fn fires(&self, f: Fault) -> bool {
547        if !self.is_armed(f) {
548            return false;
549        }
550        if f.sticky() && self.fired.lock().unwrap().contains(&f) {
551            return true;
552        }
553        let yes = match &self.weather {
554            None => true,
555            Some(m) => {
556                let mut r = m.lock().unwrap();
557                let draw = r.below(1000);
558                draw < f.seeded_rate_per_mille()
559            }
560        };
561        if yes && f.sticky() {
562            self.fired.lock().unwrap().insert(f);
563        }
564        yes
565    }
566
567    /// Everything that has fired at least once, for a run's report line.
568    pub fn fired(&self) -> Vec<Fault> {
569        self.fired.lock().unwrap().iter().copied().collect()
570    }
571}
572
573impl Default for Faults {
574    fn default() -> Self {
575        Faults::none()
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn armed_by_name_fires_every_time() {
585        let f = Faults::none();
586        f.arm(Fault::WriteUnavailable);
587        for _ in 0..100 {
588            assert!(f.fires(Fault::WriteUnavailable));
589        }
590    }
591
592    #[test]
593    fn a_disarmed_fault_never_fires() {
594        let f = Faults::quiet();
595        for v in Fault::ALL {
596            assert!(!f.fires(*v), "{}", v.name());
597        }
598    }
599
600    /// The default is not "no faults" — the stale VNC port is what the provider
601    /// does on every stop/start, and hiding it by default is how the toggle cure
602    /// went untested for a month.
603    #[test]
604    fn stale_vnc_is_the_default_weather() {
605        assert!(Faults::none().fires(Fault::StaleVncPort));
606    }
607
608    #[test]
609    fn out_of_stock_is_sticky() {
610        let f = Faults::none();
611        f.arm(Fault::OutOfStock);
612        assert!(f.fires(Fault::OutOfStock));
613        assert!(f.fires(Fault::OutOfStock));
614        f.disarm(Fault::OutOfStock);
615        assert!(!f.fires(Fault::OutOfStock));
616    }
617
618    /// A seed names a run. Two `Faults` built from the same seed must make the
619    /// same decisions in the same order, or a printed seed is decoration.
620    #[test]
621    fn a_seed_replays() {
622        let a = Faults::seeded(4242);
623        let b = Faults::seeded(4242);
624        let mut da = vec![];
625        let mut db = vec![];
626        for _ in 0..500 {
627            da.push(a.fires(Fault::WriteUnavailable));
628            db.push(b.fires(Fault::WriteUnavailable));
629        }
630        assert_eq!(da, db);
631        assert!(da.iter().any(|x| *x), "3 % of 500 should fire at least once");
632        assert!(da.iter().any(|x| !*x), "3 % of 500 should not fire every time");
633    }
634
635    #[test]
636    fn every_fault_round_trips_its_name() {
637        for f in Fault::ALL {
638            assert_eq!(Fault::parse(f.name()), Some(*f));
639        }
640        assert_eq!(Fault::parse("no-such-fault"), None);
641    }
642}