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}