Skip to main content

mock_upcloud/
self_check.rs

1//! **The calibration: prove the instrument can report the opposite, for every
2//! fault it claims to have.**
3//!
4//! A mock is an instrument, and an instrument nobody has calibrated is a source
5//! of greens rather than a source of measurements. The failure this module
6//! exists to make impossible is the one this estate committed nine times in one
7//! day: a check that could only ever say "clean", run, seen to say "clean", and
8//! believed. Behaviour 38 is the same defect written down as a fault —
9//! `Estate::detach` had no path that answered `200` without removing the
10//! device, so every sweep driven against this mock passed a test the mock could
11//! not fail.
12//!
13//! So: for every variant in [`Fault::ALL`], drive the mock twice — once with
14//! the fault DISARMED and once with it ARMED — and compare the two
15//! observations.
16//!
17//! * They differ → the fault is **expressible**. The instrument can report both
18//!   the healthy world and the faulty one, and a green from a caller driven
19//!   against it means something.
20//! * They are identical → the fault is **inexpressible**. The variant exists,
21//!   the wire name parses, `--arm` accepts it, and nothing downstream changes.
22//!   That is a fault that cannot be provoked, and [`self_check`] NAMES it and
23//!   FAILS. It does not skip it: a fault the mock cannot express is worse than
24//!   a fault it does not have, because the first one advertises a coverage it
25//!   does not possess.
26//! * Identical AND [`Fault::seeded_rate_per_mille`] is 0 → reported as a
27//!   **hypothesis**: a rate of 0 is this crate's own mark for "asked for by
28//!   name, never arrives as weather, not a measurement of the provider"
29//!   ([`Fault::WithholdCreatedField`], [`Fault::CloneSyncsLikeImport`]). It is not a
30//!   failure — but it is LISTED, with its observation printed, because absent
31//!   capability is an answer and never an empty value.
32//!
33//! Every probe runs IN PROCESS against an [`Estate`] or a [`Mock`]: no socket,
34//! no child process, nothing shelled out. The two runs are separate estates
35//! built from the same seed, so the only difference between them is the fault.
36//! The base is [`Faults::quiet`] rather than [`Faults::none`] on purpose —
37//! `none()` arms [`Fault::StaleVncPort`] and [`Fault::UdpInboundDropped`]
38//! because they are the provider's normal, and a control that already had the
39//! fault armed would compare a thing with itself.
40
41use crate::estate::{BootOrder, Estate, HostKeyPath, Label};
42use crate::{Clock, Fault, Faults, Mock};
43use serde_json::{json, Value};
44use std::sync::Arc;
45
46/// The seed every probe's estate is built from. One number, so a `--self-check`
47/// that disagrees between two boxes disagrees about the code and not the draw.
48const SEED: u64 = 4242;
49
50/// What one fault's two runs observed.
51pub struct Row {
52    pub fault: Fault,
53    /// What the probe saw with the fault DISARMED.
54    pub healthy: String,
55    /// What the same probe saw with it ARMED.
56    pub faulty: String,
57}
58
59/// What a row means.
60#[derive(Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Verdict {
62    /// The two observations differ: the mock can report both worlds.
63    Expressible,
64    /// Identical observations, and the fault is a by-name hypothesis
65    /// (`seeded_rate_per_mille() == 0`). Listed, not fatal.
66    Hypothesis,
67    /// Identical observations on a fault that is handed out as WEATHER. Fatal:
68    /// a storm arms it, a run reports it fired, and nothing about the run was
69    /// different.
70    Inexpressible,
71}
72
73impl Row {
74    pub fn verdict(&self) -> Verdict {
75        if self.healthy != self.faulty {
76            Verdict::Expressible
77        } else if self.fault.seeded_rate_per_mille() == 0 {
78            Verdict::Hypothesis
79        } else {
80            Verdict::Inexpressible
81        }
82    }
83
84    /// The rows this fault prints in the `--self-check` table: a header line
85    /// naming it, then the two observations under each other, because the whole
86    /// point is that a reader can see they are DIFFERENT.
87    pub fn line(&self) -> String {
88        let mark = match self.verdict() {
89            Verdict::Expressible => "expressible",
90            Verdict::Hypothesis => "HYPOTHESIS",
91            Verdict::Inexpressible => "INEXPRESSIBLE",
92        };
93        let weather = if self.fault.seeded_rate_per_mille() == 0 {
94            "by name".to_string()
95        } else {
96            format!("{}\u{2030}", self.fault.seeded_rate_per_mille())
97        };
98        let sticky = if self.fault.sticky() { " sticky" } else { "" };
99        format!(
100            "  {:<31} {:<14} {:>8}{}\n      disarmed  {}\n      ARMED     {}",
101            self.fault.name(),
102            mark,
103            weather,
104            sticky,
105            self.healthy,
106            self.faulty,
107        )
108    }
109}
110
111/// Every fault's calibration, and whether the whole thing passed.
112pub struct Report {
113    pub rows: Vec<Row>,
114}
115
116impl Report {
117    /// The run passes when nothing is inexpressible. A hypothesis does not fail
118    /// it; being unlisted would.
119    pub fn ok(&self) -> bool {
120        self.inexpressible().is_empty()
121    }
122
123    pub fn inexpressible(&self) -> Vec<Fault> {
124        self.pick(Verdict::Inexpressible)
125    }
126
127    pub fn hypotheses(&self) -> Vec<Fault> {
128        self.pick(Verdict::Hypothesis)
129    }
130
131    pub fn expressible(&self) -> Vec<Fault> {
132        self.pick(Verdict::Expressible)
133    }
134
135    fn pick(&self, v: Verdict) -> Vec<Fault> {
136        self.rows.iter().filter(|r| r.verdict() == v).map(|r| r.fault).collect()
137    }
138
139    /// The whole table plus its verdict, ready to print.
140    pub fn text(&self) -> String {
141        let mut out = String::from("mock-upcloud --self-check — can this instrument report the OPPOSITE?\n\n");
142        for r in &self.rows {
143            out.push_str(&r.line());
144            out.push('\n');
145        }
146        out.push_str(&format!(
147            "\n{} faults: {} expressible, {} hypothesis (rate 0, by name), {} INEXPRESSIBLE\n",
148            self.rows.len(),
149            self.expressible().len(),
150            self.hypotheses().len(),
151            self.inexpressible().len(),
152        ));
153        if self.ok() {
154            out.push_str("PASS — every fault that is handed out as weather changes what the mock answers.\n");
155        } else {
156            out.push_str("FAIL — these faults arm, parse and fire, and change NOTHING a caller can see:\n");
157            for f in self.inexpressible() {
158                out.push_str(&format!("  {}\n", f.name()));
159            }
160        }
161        out
162    }
163}
164
165/// **Run the calibration.** Every variant in [`Fault::ALL`], twice.
166///
167/// There is no way to ask for a subset: a calibration that can be narrowed is a
168/// calibration somebody will narrow to the faults that pass.
169pub fn self_check() -> Report {
170    Report { rows: Fault::ALL.iter().map(|f| Row { fault: *f, healthy: observe(*f, false), faulty: observe(*f, true) }).collect() }
171}
172
173/// A fresh estate with exactly one fault's worth of difference.
174fn estate(f: Fault, armed: bool) -> Estate {
175    Estate::new(Clock::virtual_only(), faults(f, armed), SEED)
176}
177
178fn faults(f: Fault, armed: bool) -> Faults {
179    // `quiet()`, not `none()`: `none()` already arms the two faults that are
180    // the provider's normal, and a control with the fault already armed
181    // compares a thing with itself.
182    let faults = Faults::quiet();
183    if armed {
184        faults.arm(f);
185    }
186    faults
187}
188
189fn mock(f: Fault, armed: bool) -> Arc<Mock> {
190    Mock::new(estate(f, armed))
191}
192
193fn site() -> Vec<Label> {
194    vec![
195        Label { key: "site".into(), value: "gunnar.rs".into() },
196        Label { key: "role".into(), value: "twin".into() },
197    ]
198}
199
200/// Create one server and run it up to `started`. The probes that are about a
201/// server all start here.
202fn a_server(e: &mut Estate, title: &str) -> String {
203    let uuid = e
204        .create_server(title, title, "2xCPU-4GB", "se-sto1", site(), &format!("{title}-boot"), 20)
205        .expect("the control creates a server");
206    e.run_to_quiet();
207    uuid
208}
209
210fn a_volume(e: &mut Estate, title: &str, gib: u64) -> String {
211    let uuid = e.create_storage(title, gib, "maxiops", "se-sto1", site()).expect("create a volume");
212    e.run_to_quiet();
213    uuid
214}
215
216fn status(s: u16) -> String {
217    if s == 0 {
218        "no reply at all (socket closed)".into()
219    } else {
220        s.to_string()
221    }
222}
223
224/// **The one observation per fault.** Each returns a sentence; the only thing
225/// that matters is that the armed sentence differs from the disarmed one, and
226/// that the sentence says what a CALLER would have seen rather than what a
227/// field holds.
228fn observe(f: Fault, armed: bool) -> String {
229    match f {
230        // ── the transport ───────────────────────────────────────────────────
231        Fault::PriceTransportReset => {
232            let m = mock(f, armed);
233            let (s, _) = crate::http::probe(&m, "GET", "/1.3/price", Value::Null);
234            format!("GET /1.3/price -> {}", status(s))
235        }
236
237        // ── capacity, at both doors ─────────────────────────────────────────
238        Fault::OutOfStock => {
239            let m = mock(f, armed);
240            let (cs, cv) = crate::http::probe(&m, "POST", "/1.3/server", a_server_body());
241            let created = cv["server"]["uuid"].as_str().unwrap_or("").to_string();
242            let start = if created.is_empty() {
243                "never reached".to_string()
244            } else {
245                m.estate.lock().unwrap().run_to_quiet();
246                let (ss, _) = crate::http::probe(&m, "POST", &format!("/1.3/server/{created}/stop"), json!({"stop_server": {"stop_type": "hard"}}));
247                let _ = ss;
248                m.estate.lock().unwrap().run_to_quiet();
249                let (ss, _) = crate::http::probe(&m, "POST", &format!("/1.3/server/{created}/start"), Value::Null);
250                status(ss)
251            };
252            format!("POST /1.3/server -> {} · poweron -> {}", status(cs), start)
253        }
254
255        // ── the credential ──────────────────────────────────────────────────
256        Fault::RevokedCredential => {
257            let m = mock(f, armed);
258            {
259                let mut e = m.estate.lock().unwrap();
260                a_server(&mut e, "gunnar-front");
261            }
262            let (a, _) = crate::http::probe(&m, "GET", "/1.3/account", Value::Null);
263            let (_, l) = crate::http::probe(&m, "GET", "/1.3/server", Value::Null);
264            let rows = l["servers"]["server"].as_array().map(|a| a.len()).unwrap_or(0);
265            format!("GET /1.3/account -> {} · the list shows {rows} of the 1 server that exists", status(a))
266        }
267
268        // ── the console ─────────────────────────────────────────────────────
269        Fault::StaleVncPort => {
270            let mut e = estate(f, armed);
271            let uuid = a_server(&mut e, "gunnar-appliance");
272            e.modify_server(&uuid, None, None, None, Some(true), None).expect("remote access on");
273            e.stop_server(&uuid, true).expect("stop");
274            e.run_to_quiet();
275            e.start_server(&uuid).expect("start");
276            e.run_to_quiet();
277            let s = e.server(&uuid).expect("still there");
278            let (_, reported) = s.console().expect("remote access is on");
279            if reported == s.vnc_port {
280                "after a stop/start the API reports the port the hypervisor is really on".into()
281            } else {
282                "after a stop/start the API reports the port from BEFORE the restart".into()
283            }
284        }
285
286        // ── the boot order ──────────────────────────────────────────────────
287        Fault::InstallerLoop => {
288            let mut e = estate(f, armed);
289            let uuid = a_server(&mut e, "gunnar-appliance");
290            let medium = a_volume(&mut e, "korp-installer.iso", 1);
291            e.stop_server(&uuid, true).expect("stop");
292            e.run_to_quiet();
293            e.attach(&uuid, &medium, "cdrom").expect("the media go on while it is stopped");
294            e.modify_server(&uuid, None, Some(BootOrder::Cdrom), None, None, None).expect("cd first");
295            e.start_server(&uuid).expect("start");
296            e.run_to_quiet();
297            match e.server(&uuid).map(|s| s.guest) {
298                Some(crate::estate::Guest::Looping { rounds }) => {
299                    format!("the guest is running the installer AGAIN (round {rounds}) with the CD still first")
300                }
301                Some(g) => format!("the guest installed once and settled as {g:?}"),
302                None => "the server vanished".into(),
303            }
304        }
305
306        // ── behaviour 69: which kind of medium ──────────────────────────────
307        Fault::InstallerReboots => {
308            let mut e = estate(f, armed);
309            let uuid = a_server(&mut e, "gunnar-appliance");
310            let medium = a_volume(&mut e, "korp-installer.iso", 1);
311            e.stop_server(&uuid, true).expect("stop");
312            e.run_to_quiet();
313            e.attach(&uuid, &medium, "cdrom").expect("the media go on while it is stopped");
314            e.modify_server(&uuid, None, Some(BootOrder::Cdrom), None, None, None).expect("cd first");
315            e.start_server(&uuid).expect("start");
316            // 200 s after the start: past a power-off pass (130–146 s), well
317            // inside a rebooting one (900–1100 s). Stepped, so each
318            // transition chains from its own moment.
319            for _ in 0..2000 {
320                e.clock.advance_ms(100);
321                e.settle();
322            }
323            match e.server(&uuid).map(|s| s.state.clone()) {
324                Some(st) => format!("200 s after an installer start the server reads `{st}`"),
325                None => "the server vanished".into(),
326            }
327        }
328
329        // ── the field that is sent ──────────────────────────────────────────
330        Fault::WithholdCreatedField => {
331            let m = mock(f, armed);
332            {
333                let mut e = m.estate.lock().unwrap();
334                a_volume(&mut e, "twin-data", 44);
335            }
336            let (_, l) = crate::http::probe(&m, "GET", "/1.3/storage/private", Value::Null);
337            let row = &l["storages"]["storage"][0];
338            if row["created"].is_null() {
339                "the storage row carries NO `created` — a young volume and a six-month orphan are the same row".into()
340            } else {
341                "the storage row carries `created`, as the account does".into()
342            }
343        }
344
345        // ── the ordinary wobble ─────────────────────────────────────────────
346        Fault::WriteUnavailable => {
347            let m = mock(f, armed);
348            let (s, _) = crate::http::probe(&m, "POST", "/1.3/storage", json!({"storage": {"title": "twin-data", "size": 44, "tier": "maxiops", "zone": "se-sto1"}}));
349            format!("POST /1.3/storage -> {}", status(s))
350        }
351
352        // ── what the resize leaves behind ───────────────────────────────────
353        Fault::OrphanResizeBackup => {
354            let (_, backup, e) = a_resize(f, armed);
355            let origin = backup.clone().and_then(|b| e.storage(&b).and_then(|s| s.origin.clone()));
356            match origin {
357                None => "no backup was minted at all".into(),
358                Some(o) if e.storage(&o).is_some() => "the backup's `origin` resolves to the volume it was taken from".into(),
359                Some(_) => "the backup's `origin` names a uuid that resolves to NOTHING".into(),
360            }
361        }
362
363        Fault::ResizeBackupUnlabelled => {
364            let (_, backup, e) = a_resize(f, armed);
365            match backup.and_then(|b| e.storage(&b).map(|s| s.labels.len())) {
366                None => "no backup was minted at all".into(),
367                Some(0) => "the backup carries NO labels — only its `origin` ties it to the estate".into(),
368                Some(n) => format!("the backup carries the volume's {n} labels"),
369            }
370        }
371
372        // ── the reply that never arrives ────────────────────────────────────
373        Fault::CommitThenDropReply => {
374            let m = mock(f, armed);
375            let (s, _) = crate::http::probe(&m, "POST", "/1.3/server", a_server_body());
376            let committed = m.estate.lock().unwrap().all_servers().count();
377            format!("POST /1.3/server -> {} · {committed} server committed at the provider", status(s))
378        }
379
380        // ── the two clocks of an import ─────────────────────────────────────
381        Fault::ImportFailed => {
382            let (uuid, e, _) = an_import(f, armed);
383            let st = e.storage(&uuid).expect("the volume");
384            let im = st.import.as_ref().map(|i| i.state.clone()).unwrap_or_default();
385            format!("after the bytes arrived: storage `{}`, import `{im}`", st.state)
386        }
387
388        Fault::SyncExceedsBudget => {
389            let (uuid, e, ms) = an_import(f, armed);
390            let st = e.storage(&uuid).expect("the volume");
391            // The caller's budget is 1200 s. Reported as over or under it
392            // rather than as a raw number, because the number is drawn.
393            let verdict = if ms > 1_200_000 { "OVER the caller's 1200 s budget" } else { "inside the caller's 1200 s budget" };
394            format!("the volume reached `{}` {} s after the upload — {verdict}", st.state, ms / 1000)
395        }
396
397        // ── the candidate fix ───────────────────────────────────────────────
398        Fault::CloneSyncsLikeImport => {
399            let mut e = estate(f, armed);
400            let src = a_volume(&mut e, "korp-installer.iso", 1);
401            let new = e.clone_storage(&src, "korp-installer.iso (clone)").expect("clone");
402            let mut saw_syncing = false;
403            for _ in 0..2000 {
404                e.tick();
405                match e.storage(&new).map(|s| s.state.as_str()) {
406                    Some("syncing") => saw_syncing = true,
407                    Some("online") => break,
408                    _ => {}
409                }
410            }
411            if saw_syncing {
412                "the clone waits in `syncing`, exactly as an import does".into()
413            } else {
414                "the clone goes straight to `online` and never enters `syncing`".into()
415            }
416        }
417
418        // ── the guest's own software ────────────────────────────────────────
419        Fault::GuestReadsRtcAsLocalTime => {
420            let mut e = estate(f, armed);
421            let uuid = a_server(&mut e, "gunnar-appliance");
422            // 2026-09-20, the day the pair was measured. CEST.
423            let t = crate::guest_clock::days_from_civil(2026, 9, 20) * 86_400;
424            let skew = e.server(&uuid).expect("the server").clock_skew_ms(t);
425            format!("the guest's wall clock is {skew} ms from the truth")
426        }
427
428        Fault::UdpInboundDropped => {
429            // No estate needed: this one is about a packet, and the packet has
430            // no state.
431            let t = 1_789_000_000i64;
432            match crate::guest_clock::ntp_answer(&faults(f, armed), t) {
433                Some(_) => "an NTP query is answered".into(),
434                None => "an NTP query is never answered — silence, not an error".into(),
435            }
436        }
437
438        Fault::GuestIgnoresDhcpOption121 => {
439            let mut e = estate(f, armed);
440            let uuid = a_server(&mut e, "gunnar-appliance");
441            // **The destination has to be a box that is really listening, in
442            // the OTHER /22.** A made-up far address proves nothing here: it is
443            // in neither utility prefix, so the routing branch does not apply
444            // and both runs answer `connection refused` — which is how the
445            // first draft of this probe reported `guest-ignores-dhcp-option-121`
446            // as INEXPRESSIBLE when the fault works perfectly. The calibration
447            // caught its own probe, which is the argument for having it.
448            //
449            // The pool is shuffled per lay, so the second server is not
450            // guaranteed to land in the other prefix: create until one does.
451            let own = e.server(&uuid).map(|s| s.utility_ip.clone()).unwrap_or_default();
452            let own_net = crate::net::net_of(&own);
453            let mut dest = String::new();
454            for n in 0..24 {
455                let peer = a_server(&mut e, &format!("gunnar-front-{n}"));
456                let ip = e.server(&peer).map(|s| s.utility_ip.clone()).unwrap_or_default();
457                if crate::net::net_of(&ip) != own_net {
458                    dest = ip;
459                    break;
460                }
461            }
462            assert!(!dest.is_empty(), "the address pool must span both utility prefixes");
463            let reach = e.reach(&uuid, &dest, 443).expect("the server is there");
464            format!("outbound to a live box in the other utility /22: {}", reach.why())
465        }
466
467        // ── the name ────────────────────────────────────────────────────────
468        Fault::HijackedName => {
469            let mut e = estate(f, armed);
470            let uuid = a_server(&mut e, "gunnar-appliance");
471            let name = e.host_key_via(&uuid, HostKeyPath::Name).expect("a key on the name");
472            let direct = e.host_key_via(&uuid, HostKeyPath::Direct).expect("a key on the direct path");
473            if name == direct {
474                "the name and the direct path answer the SAME host key".into()
475            } else {
476                "the name answers a DIFFERENT host key from the direct path".into()
477            }
478        }
479
480        // ── the account contradicting itself ────────────────────────────────
481        Fault::FirewallForbidden => {
482            let m = mock(f, armed);
483            let uuid = {
484                let mut e = m.estate.lock().unwrap();
485                a_server(&mut e, "gunnar-front")
486            };
487            let (d, _) = crate::http::probe(&m, "GET", &format!("/1.3/server/{uuid}"), Value::Null);
488            let (fw, _) = crate::http::probe(&m, "GET", &format!("/1.3/server/{uuid}/firewall_rule"), Value::Null);
489            format!("a LIVE server: detail -> {} · firewall_rule -> {}", status(d), status(fw))
490        }
491
492        Fault::DetailNotFoundForListedServer => {
493            let m = mock(f, armed);
494            let uuid = {
495                let mut e = m.estate.lock().unwrap();
496                a_server(&mut e, "gunnar-front")
497            };
498            let (_, l) = crate::http::probe(&m, "GET", "/1.3/server", Value::Null);
499            let rows = l["servers"]["server"].as_array().map(|a| a.len()).unwrap_or(0);
500            let (d, _) = crate::http::probe(&m, "GET", &format!("/1.3/server/{uuid}"), Value::Null);
501            format!("the list shows {rows} server · its own detail -> {}", status(d))
502        }
503
504        // ── the write that lies about itself ────────────────────────────────
505        Fault::DetachSaysSuccessButStaysAttached => {
506            let mut e = estate(f, armed);
507            let uuid = a_server(&mut e, "gunnar-twin");
508            let member = a_volume(&mut e, "twin-data", 44);
509            e.attach(&uuid, &member, "disk").expect("hot-plug the member");
510            e.stop_server(&uuid, true).expect("stop");
511            e.run_to_quiet();
512            let before = e.server(&uuid).map(|s| s.devices.len()).unwrap_or(0);
513            let answer = match e.detach(&uuid, "virtio:1") {
514                Ok(()) => "200".to_string(),
515                Err(x) => format!("{} {}", x.status, x.code),
516            };
517            let after = e.server(&uuid).map(|s| s.devices.len()).unwrap_or(0);
518            let still = if after < before { "and the device is GONE" } else { "and the device is STILL ATTACHED" };
519            format!("detach -> {answer} {still} on the read-back")
520        }
521
522        // ── lane T13 ────────────────────────────────────────────────────────
523        Fault::DeadToken => {
524            let m = mock(f, armed);
525            let (a, _) = crate::http::probe(&m, "GET", "/1.3/account", Value::Null);
526            let (l, _) = crate::http::probe(&m, "GET", "/1.3/server", Value::Null);
527            format!("GET /1.3/account -> {} · GET /1.3/server -> {}", status(a), status(l))
528        }
529        Fault::ReadBadGateway => {
530            let m = mock(f, armed);
531            let (l, _) = crate::http::probe(&m, "GET", "/1.3/server", Value::Null);
532            format!("GET /1.3/server -> {}", status(l))
533        }
534        Fault::GuestKernelLacksHotplug => {
535            let mut e = estate(f, armed);
536            let uuid = a_server(&mut e, "gunnar-appliance");
537            let member = a_volume(&mut e, "member", 10);
538            match e.attach(&uuid, &member, "disk") {
539                Ok(a) => format!("a virtio attach on the running server -> 200 at {a}"),
540                Err(x) => format!("a virtio attach on the running server -> {} {}", x.status, x.code),
541            }
542        }
543        Fault::ResizeRequiresDetach => {
544            let mut e = estate(f, armed);
545            let uuid = a_server(&mut e, "gunnar-twin");
546            let member = a_volume(&mut e, "twin-data", 20);
547            e.attach(&uuid, &member, "disk").expect("attach");
548            e.stop_server(&uuid, true).expect("stop");
549            e.run_to_quiet();
550            match e.modify_storage(&member, Some(30), None) {
551                Ok(()) => "a grow of a volume attached to a STOPPED server -> 200".into(),
552                Err(x) => format!("a grow of a volume attached to a STOPPED server -> {} {}", x.status, x.code),
553            }
554        }
555    }
556}
557
558/// The body a create sends. One place, so the two runs of a probe cannot differ
559/// by a typo in a plan name.
560fn a_server_body() -> Value {
561    json!({"server": {
562        "zone": "se-sto1", "title": "gunnar-front", "hostname": "gunnar-front", "plan": "2xCPU-4GB",
563        "storage_devices": {"storage_device": [{"action": "create", "title": "boot", "size": 20, "tier": "maxiops"}]}
564    }})
565}
566
567/// Grow a volume and resize its filesystem, which is the door behaviour 34
568/// found. Returns the volume, the backup it left behind, and the estate to read
569/// them out of.
570fn a_resize(f: Fault, armed: bool) -> (String, Option<String>, Estate) {
571    let mut e = estate(f, armed);
572    let vol = a_volume(&mut e, "twin-data", 44);
573    let backup = e.resize_filesystem(&vol).ok();
574    e.run_to_quiet();
575    (vol, backup, e)
576}
577
578/// Open a direct upload, PUT some bytes, and run the two clocks out. Returns
579/// the volume, the estate, and how long after the upload the volume settled.
580fn an_import(f: Fault, armed: bool) -> (String, Estate, u64) {
581    let mut e = estate(f, armed);
582    let uuid = a_volume(&mut e, "korp-installer.iso", 1);
583    e.start_import(&uuid, "direct_upload").expect("open the session");
584    // Small on purpose: the upload's own clock is not what either of these two
585    // faults is about, and a 43 MB buffer in a self-check is 43 MB of nothing.
586    e.upload(&uuid, &[0u8; 4096]).expect("the bytes arrive");
587    let t0 = e.clock.now_ms();
588    e.run_to_quiet();
589    let ms = e.clock.now_ms().saturating_sub(t0);
590    (uuid, e, ms)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    /// **The calibration passes, and it covers everything.** A row per variant,
598    /// no variant skipped, and nothing inexpressible.
599    #[test]
600    fn every_fault_can_be_reported_both_ways() {
601        let r = self_check();
602        assert_eq!(r.rows.len(), Fault::ALL.len(), "one row per fault, always");
603        for row in &r.rows {
604            assert_ne!(row.healthy, "", "{} observed nothing disarmed", row.fault.name());
605            assert_ne!(row.faulty, "", "{} observed nothing armed", row.fault.name());
606        }
607        assert!(
608            r.ok(),
609            "these faults change nothing a caller can see: {:?}\n{}",
610            r.inexpressible().iter().map(|f| f.name()).collect::<Vec<_>>(),
611            r.text()
612        );
613    }
614
615    /// **The calibration can FAIL.** The control for the control: a row whose
616    /// two observations are identical is reported as a failure when the fault
617    /// is weather, and as a listed hypothesis when it is by-name. Without this,
618    /// `self_check` would be exactly the kind of check it exists to forbid —
619    /// one nobody has seen say no.
620    #[test]
621    fn a_fault_that_changes_nothing_is_named_and_fails() {
622        // `StaleVncPort` is weather (1000‰). Identical observations on it are
623        // fatal.
624        let weather = Row {
625            fault: Fault::StaleVncPort,
626            healthy: "the same thing".into(),
627            faulty: "the same thing".into(),
628        };
629        assert_eq!(weather.verdict(), Verdict::Inexpressible);
630        let r = Report { rows: vec![weather] };
631        assert!(!r.ok());
632        assert_eq!(r.inexpressible(), vec![Fault::StaleVncPort]);
633        assert!(r.text().contains("FAIL"), "{}", r.text());
634        assert!(r.text().contains("stale-vnc-port"), "the failure NAMES it: {}", r.text());
635
636        // A rate-0 fault is a hypothesis: listed, printed, not fatal.
637        let by_name = Row {
638            fault: Fault::CloneSyncsLikeImport,
639            healthy: "the same thing".into(),
640            faulty: "the same thing".into(),
641        };
642        assert_eq!(by_name.verdict(), Verdict::Hypothesis);
643        let r = Report { rows: vec![by_name] };
644        assert!(r.ok(), "a hypothesis does not fail the run");
645        assert_eq!(r.hypotheses(), vec![Fault::CloneSyncsLikeImport]);
646        assert!(r.text().contains("clone-syncs-like-import"), "but it is LISTED: {}", r.text());
647    }
648
649    /// The table names every fault, so a fault cannot pass by being absent from
650    /// the output a person reads.
651    #[test]
652    fn the_printed_table_names_every_fault() {
653        let t = self_check().text();
654        for f in Fault::ALL {
655            assert!(t.contains(f.name()), "{} is missing from the table:\n{t}", f.name());
656        }
657    }
658}