1use crate::estate::{BootOrder, Estate, HostKeyPath, Label};
42use crate::{Clock, Fault, Faults, Mock};
43use serde_json::{json, Value};
44use std::sync::Arc;
45
46const SEED: u64 = 4242;
49
50pub struct Row {
52 pub fault: Fault,
53 pub healthy: String,
55 pub faulty: String,
57}
58
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Verdict {
62 Expressible,
64 Hypothesis,
67 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 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
111pub struct Report {
113 pub rows: Vec<Row>,
114}
115
116impl Report {
117 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 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
165pub 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
173fn 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 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
200fn 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
224fn observe(f: Fault, armed: bool) -> String {
229 match f {
230 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 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 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 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 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 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 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 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 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 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 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 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 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 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 Fault::GuestReadsRtcAsLocalTime => {
420 let mut e = estate(f, armed);
421 let uuid = a_server(&mut e, "gunnar-appliance");
422 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 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 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 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 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 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 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
558fn 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
567fn 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
578fn 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 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 #[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 #[test]
621 fn a_fault_that_changes_nothing_is_named_and_fails() {
622 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 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 #[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}