Skip to main content

helixforge_engine/
reliability.rs

1//! How far each entry has been tested: real installs in distribution
2//! containers (recorded by the Distributions workflow and carried by release
3//! builds), installer tests with stand-in commands, and reports from virtual
4//! and real machines (`catalog/validation.toml`), with this machine's own
5//! runs beside them. Nothing here claims more than its record shows.
6
7use crate::history;
8use helixforge_core::{Action, Entry, Risk};
9use serde::Deserialize;
10use std::{
11    collections::BTreeMap,
12    path::{Path, PathBuf},
13};
14
15/// How far an entry has been validated, best first.
16#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Level {
19    /// Run on a real machine, from a tester's report.
20    HardwareTested,
21    /// Run in a virtual machine, from a report.
22    VmTested,
23    /// Installed (or run) and verified in a distribution container by the
24    /// Distributions workflow.
25    ContainerTested,
26    /// Its installer was run against stand-in commands and files.
27    StandInTested,
28    NotYetValidated,
29}
30
31impl Level {
32    pub fn label(self) -> &'static str {
33        match self {
34            Level::HardwareTested => "Hardware Tested",
35            Level::VmTested => "VM Tested",
36            Level::ContainerTested => "Container Tested",
37            Level::StandInTested => "Stand-in Tested",
38            Level::NotYetValidated => "Not Yet Validated",
39        }
40    }
41
42    pub fn explain(self) -> &'static str {
43        match self {
44            Level::HardwareTested => "A tester ran it on a real machine and reported the result.",
45            Level::VmTested => {
46                "It ran for real in a virtual machine: the Distributions workflow boots a distribution's cloud image or live ISO, runs the entry, reboots into the result and checks it (the configuration, the kernel command line, the boot images, the services, a graphical boot menu or login screen). Real graphics hardware and firmware are not covered."
47            }
48            Level::ContainerTested => {
49                "The Distributions workflow ran it in distribution containers and HelixForge verified the result there. Containers have no boot loader, login screen or desktop, so those parts are not covered."
50            }
51            Level::StandInTested => {
52                "Installer tests run its script, or the helpers it calls, against stand-in commands and files, on every change and in each distribution's container. They show what it would change, without changing a system."
53            }
54            Level::NotYetValidated => {
55                "No automated test runs it and nobody has reported running it. Its plan is built and checked, like every entry's."
56            }
57        }
58    }
59}
60
61/// `catalog/validation.toml`.
62#[derive(Clone, Debug, Default, Deserialize)]
63pub struct Claims {
64    #[serde(default)]
65    pub critical: Critical,
66    #[serde(default, rename = "stand-ins")]
67    pub stand_ins: Vec<StandIns>,
68    #[serde(default)]
69    pub hardware: Vec<Report>,
70    #[serde(default)]
71    pub vm: Vec<Report>,
72}
73
74#[derive(Clone, Debug, Default, Deserialize)]
75pub struct Critical {
76    #[serde(default)]
77    pub entries: Vec<String>,
78}
79
80/// Installer tests that cover entries with stand-ins.
81#[derive(Clone, Debug, Default, Deserialize)]
82pub struct StandIns {
83    pub entries: Vec<String>,
84    pub tests: Vec<String>,
85    #[serde(default)]
86    pub covers: String,
87}
88
89/// A run reported from a virtual or real machine.
90#[derive(Clone, Debug, Default, Deserialize)]
91pub struct Report {
92    pub entry: String,
93    pub system: String,
94    #[serde(default)]
95    pub version: String,
96    pub date: String,
97    #[serde(default)]
98    pub note: String,
99}
100
101/// What a Distributions run verified in each container, as release builds
102/// carry it (`catalog/validation-results.json`).
103#[derive(Clone, Debug, Default, Deserialize)]
104pub struct Results {
105    #[serde(default)]
106    pub commit: String,
107    #[serde(default)]
108    pub run: u64,
109    #[serde(default)]
110    pub url: String,
111    /// When the run finished, in Unix seconds.
112    #[serde(default)]
113    pub finished: u64,
114    /// By container image: each entry and what was shown (`verified`, or
115    /// `ran` for an entry with nothing to verify).
116    #[serde(default)]
117    pub images: BTreeMap<String, Image>,
118    /// By virtual machine (arch, debian): the entries run there, rebooted
119    /// into and checked by scripts/vm-tests.py.
120    #[serde(default)]
121    pub vms: BTreeMap<String, Image>,
122}
123
124#[derive(Clone, Debug, Default, Deserialize)]
125pub struct Image {
126    #[serde(default)]
127    pub entries: BTreeMap<String, String>,
128}
129
130/// Everything known about testing, from this build's catalog.
131#[derive(Clone, Debug, Default)]
132pub struct Evidence {
133    pub claims: Claims,
134    pub results: Option<Results>,
135    /// Why a file could not be read, if one could not.
136    pub problems: Vec<String>,
137}
138
139/// One entry's standing.
140#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
141pub struct Assessment {
142    pub level: Level,
143    /// Where the tests ran: container images or reported systems.
144    pub systems: Vec<String>,
145    /// When it was last verified, as a date, when known.
146    pub verified: Option<String>,
147    /// What shows it, line by line.
148    pub evidence: Vec<String>,
149    /// Its last successful run on this machine, in Unix seconds.
150    pub here: Option<u64>,
151    /// A boot, login-screen, driver or whole-system entry.
152    pub critical: bool,
153}
154
155/// A virtual machine as people name it: "Arch VM".
156pub fn vm_label(name: &str) -> String {
157    match name {
158        "arch" | "arch-themes" => "Arch VM".to_string(),
159        "debian" => "Debian VM".to_string(),
160        "fedora" => "Fedora VM".to_string(),
161        "opensuse" => "openSUSE VM".to_string(),
162        "arch-uefi" => "Arch UEFI VM".to_string(),
163        "arch-iso" => "Arch live ISO VM".to_string(),
164        "steamdeck" => "Steam Deck stand-in VM".to_string(),
165        other => format!("{} VM", helixforge_core::printable(other)),
166    }
167}
168
169/// A container image as people name the system.
170pub fn image_label(image: &str) -> String {
171    // The ARM64 build's containers: "debian:stable on aarch64".
172    if let Some(image) = image.strip_suffix(" on aarch64") {
173        return format!("{} on ARM64", image_label(image));
174    }
175    match image {
176        "archlinux:latest" => "Arch".to_string(),
177        "archlinuxarm" => "Arch Linux ARM".to_string(),
178        "debian:stable" => "Debian".to_string(),
179        "fedora:latest" => "Fedora".to_string(),
180        "opensuse/tumbleweed:latest" => "openSUSE Tumbleweed".to_string(),
181        other => match other.split_once(':') {
182            Some(("ubuntu", version)) => format!("Ubuntu {version}"),
183            _ => helixforge_core::printable(other),
184        },
185    }
186}
187
188impl Evidence {
189    /// Read this build's record: `catalog/validation.toml` and, in a
190    /// release build, `catalog/validation-results.json`.
191    pub fn load(root: &Path) -> Self {
192        let mut evidence = Evidence::default();
193        let catalog = root.join("catalog");
194        let claims = catalog.join("validation.toml");
195        match std::fs::read_to_string(&claims) {
196            Ok(text) => match toml::from_str(&text) {
197                Ok(claims) => evidence.claims = claims,
198                Err(error) => evidence.problems.push(format!(
199                    "{}: {}",
200                    claims.display(),
201                    helixforge_core::printable(error.message())
202                )),
203            },
204            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
205            Err(error) => evidence
206                .problems
207                .push(format!("{}: {error}", claims.display())),
208        }
209        let results: PathBuf = catalog.join("validation-results.json");
210        if let Ok(text) = std::fs::read_to_string(&results) {
211            match serde_json::from_str(&text) {
212                Ok(results) => evidence.results = Some(results),
213                Err(error) => evidence
214                    .problems
215                    .push(format!("{}: {error}", results.display())),
216            }
217        }
218        evidence
219    }
220
221    /// Whether failing would leave the machine without a working boot,
222    /// login screen or display, or the entry changes disks or the whole
223    /// system.
224    pub fn critical(&self, entry: &Entry) -> bool {
225        Risk::from_flags(&entry.flags) == Risk::High
226            || self.claims.critical.entries.contains(&entry.id)
227    }
228
229    /// How an entry stands; `history` is this machine's, newest first.
230    pub fn assess(&self, entry: &Entry, history: &[history::Record]) -> Assessment {
231        let here = history
232            .iter()
233            .find(|record| record.success && record.entries.contains(&entry.id))
234            .map(|record| record.unix_timestamp);
235        let mut assessment = Assessment {
236            level: Level::NotYetValidated,
237            systems: Vec::new(),
238            verified: None,
239            evidence: Vec::new(),
240            here,
241            critical: self.critical(entry),
242        };
243        let better = |assessment: &mut Assessment, level: Level| {
244            if level < assessment.level {
245                assessment.level = level;
246            }
247        };
248        for (kind, reports, level) in [
249            (
250                "a real machine",
251                &self.claims.hardware,
252                Level::HardwareTested,
253            ),
254            ("a virtual machine", &self.claims.vm, Level::VmTested),
255        ] {
256            for report in reports.iter().filter(|report| report.entry == entry.id) {
257                better(&mut assessment, level);
258                let system = helixforge_core::printable(&report.system);
259                if !assessment.systems.contains(&system) {
260                    assessment.systems.push(system.clone());
261                }
262                let date = helixforge_core::printable(&report.date);
263                if assessment
264                    .verified
265                    .as_ref()
266                    .is_none_or(|known| *known < date)
267                {
268                    assessment.verified = Some(date.clone());
269                }
270                let mut line = format!("Ran on {kind}: {system}, {date}");
271                if !report.version.is_empty() {
272                    line.push_str(&format!(
273                        ", HelixForge {}",
274                        helixforge_core::printable(&report.version)
275                    ));
276                }
277                if !report.note.is_empty() {
278                    line.push_str(&format!(" ({})", helixforge_core::printable(&report.note)));
279                }
280                assessment.evidence.push(line);
281            }
282        }
283        if let Some(results) = &self.results {
284            let date = crate::date(results.finished);
285            let machines: Vec<String> = results
286                .vms
287                .iter()
288                .filter(|(_, found)| {
289                    found
290                        .entries
291                        .get(&entry.id)
292                        .is_some_and(|outcome| outcome == "verified")
293                })
294                .map(|(name, _)| vm_label(name))
295                .collect();
296            if !machines.is_empty() {
297                better(&mut assessment, Level::VmTested);
298                for machine in &machines {
299                    if !assessment.systems.contains(machine) {
300                        assessment.systems.push(machine.clone());
301                    }
302                }
303                if assessment
304                    .verified
305                    .as_ref()
306                    .is_none_or(|known| *known < date)
307                {
308                    assessment.verified = Some(date.clone());
309                }
310                assessment.evidence.push(format!(
311                    "In virtual machines, Distributions run {} on {date}: {}; run for real, rebooted into and checked",
312                    results.run,
313                    machines.join(", ")
314                ));
315            }
316            let mut shown: Vec<String> = Vec::new();
317            for (image, found) in &results.images {
318                if let Some(outcome) = found.entries.get(&entry.id) {
319                    shown.push(format!(
320                        "{} ({})",
321                        image_label(image),
322                        if outcome == "verified" {
323                            "installed and verified"
324                        } else {
325                            "ran"
326                        }
327                    ));
328                    let label = image_label(image);
329                    if !assessment.systems.contains(&label) {
330                        assessment.systems.push(label);
331                    }
332                }
333            }
334            if !shown.is_empty() {
335                better(&mut assessment, Level::ContainerTested);
336                if assessment
337                    .verified
338                    .as_ref()
339                    .is_none_or(|known| *known < date)
340                {
341                    assessment.verified = Some(date.clone());
342                }
343                assessment.evidence.push(format!(
344                    "In containers, Distributions run {} on {date}: {}",
345                    results.run,
346                    shown.join(", ")
347                ));
348            }
349        }
350        for stand_ins in self
351            .claims
352            .stand_ins
353            .iter()
354            .filter(|group| group.entries.contains(&entry.id))
355        {
356            better(&mut assessment, Level::StandInTested);
357            let mut line = format!(
358                "Installer tests with stand-ins ({}): {}",
359                stand_ins.tests.join(", "),
360                helixforge_core::printable(&stand_ins.covers)
361            );
362            if let Some(results) = &self.results {
363                let date = crate::date(results.finished);
364                line.push_str(&format!(
365                    "; they ran in all {} containers on {date}",
366                    results.images.len()
367                ));
368                if assessment.level == Level::StandInTested {
369                    assessment.verified = Some(date);
370                    for image in results.images.keys() {
371                        let label = image_label(image);
372                        if !assessment.systems.contains(&label) {
373                            assessment.systems.push(label);
374                        }
375                    }
376                }
377            }
378            assessment.evidence.push(line);
379        }
380        if let Action::Package { .. } = entry.action {
381            assessment.evidence.push(
382                "Its commands are built by HelixForge from package names, and tested against harmless stand-ins on every change.".to_string(),
383            );
384        }
385        if let Some(when) = here {
386            assessment.evidence.push(format!(
387                "Ran successfully on this machine, {}",
388                crate::date(when)
389            ));
390        }
391        assessment
392    }
393
394    /// A line on the record this build carries, for the screen's header.
395    pub fn summary(&self) -> String {
396        match &self.results {
397            Some(results) => format!(
398                "Container record: Distributions run {} on commit {}, {}, {} distributions{}.",
399                results.run,
400                results.commit.chars().take(12).collect::<String>(),
401                crate::date(results.finished),
402                results.images.len(),
403                match results.vms.len() {
404                    0 => String::new(),
405                    1 => " and 1 virtual machine".to_string(),
406                    count => format!(" and {count} virtual machines"),
407                }
408            ),
409            None => "No container record in this build; release builds carry one.".to_string(),
410        }
411    }
412}
413
414/// Warnings for a plan's entries that are critical and not validated past
415/// stand-ins, shown before it runs.
416pub fn warnings<'a>(
417    ids: impl IntoIterator<Item = &'a str>,
418    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
419    evidence: &Evidence,
420) -> Vec<String> {
421    let mut warnings = Vec::new();
422    for id in ids {
423        let Some(entry) = lookup(id) else { continue };
424        if !evidence.critical(entry) {
425            continue;
426        }
427        let assessment = evidence.assess(entry, &[]);
428        if assessment.level >= Level::ContainerTested {
429            warnings.push(format!(
430                "{} is {}: {}",
431                entry.name,
432                assessment.level.label(),
433                match assessment.level {
434                    Level::NotYetValidated => "nothing has tested it yet",
435                    Level::StandInTested => "only tested with stand-ins, never on a real system",
436                    _ => "containers cannot test boot, login or drivers",
437                }
438            ));
439        }
440    }
441    warnings
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use helixforge_catalog::Catalog;
448
449    fn evidence(claims: &str, results: Option<&str>) -> Evidence {
450        Evidence {
451            claims: toml::from_str(claims).unwrap(),
452            results: results.map(|text| serde_json::from_str(text).unwrap()),
453            problems: Vec::new(),
454        }
455    }
456
457    const RESULTS: &str = r#"{"format": 1, "commit": "0123456789abcdef0123", "run": 42,
458        "finished": 1792172552,
459        "images": {"archlinux:latest": {"entries": {"htop": "verified"}},
460                   "ubuntu:24.04": {"entries": {"htop": "verified", "system-report": "ran"}}},
461        "vms": {"arch": {"entries": {"sddm": "verified"}}}}"#;
462
463    #[test]
464    fn container_images_are_named_as_systems() {
465        assert_eq!(image_label("debian:stable"), "Debian");
466        assert_eq!(image_label("ubuntu:22.04"), "Ubuntu 22.04");
467        assert_eq!(
468            image_label("opensuse/tumbleweed:latest on aarch64"),
469            "openSUSE Tumbleweed on ARM64"
470        );
471        assert_eq!(
472            image_label("archlinuxarm on aarch64"),
473            "Arch Linux ARM on ARM64"
474        );
475    }
476
477    #[test]
478    fn levels_come_from_the_record_never_beyond_it() {
479        let catalog = Catalog::builtin(false).unwrap();
480        let found = evidence(
481            r#"
482            [critical]
483            entries = ["sddm", "grub"]
484            [[stand-ins]]
485            entries = ["sddm"]
486            tests = ["test_sddm"]
487            covers = "the theme"
488            [[hardware]]
489            entry = "grub"
490            system = "Garuda Linux"
491            date = "2026-10-20"
492            "#,
493            Some(RESULTS),
494        );
495        let assess = |id: &str| found.assess(catalog.find(id).unwrap(), &[]);
496        let htop = assess("htop");
497        assert_eq!(htop.level, Level::ContainerTested);
498        assert_eq!(htop.systems, ["Arch", "Ubuntu 24.04"]);
499        assert_eq!(htop.verified.as_deref(), Some("2026-10-16"));
500        assert!(!htop.critical);
501        assert_eq!(assess("system-report").level, Level::ContainerTested);
502        // Verified in a virtual machine: above its stand-in tests.
503        let sddm = assess("sddm");
504        assert_eq!(sddm.level, Level::VmTested);
505        assert!(sddm.critical);
506        assert!(
507            sddm.systems.contains(&"Arch VM".to_string()),
508            "{:?}",
509            sddm.systems
510        );
511        assert!(
512            sddm.evidence[0].contains("virtual machines"),
513            "{:?}",
514            sddm.evidence
515        );
516        assert!(sddm.evidence.iter().any(|line| line.contains("test_sddm")));
517        assert!(
518            found.summary().contains(" and 1 virtual machine."),
519            "{}",
520            found.summary()
521        );
522        let grub = assess("grub");
523        assert_eq!(grub.level, Level::HardwareTested);
524        assert_eq!(grub.verified.as_deref(), Some("2026-10-20"));
525        assert_eq!(assess("ripgrep").level, Level::NotYetValidated);
526        // This machine's runs show beside the level, never raise it.
527        let ran = [history::Record {
528            unix_timestamp: 1_700_000_000,
529            entries: vec!["ripgrep".into()],
530            names: Vec::new(),
531            success: true,
532            result: String::new(),
533            log: None,
534        }];
535        let ripgrep = found.assess(catalog.find("ripgrep").unwrap(), &ran);
536        assert_eq!(ripgrep.level, Level::NotYetValidated);
537        assert_eq!(ripgrep.here, Some(1_700_000_000));
538
539        // Before a run, critical entries short of a real test are named.
540        // SDDM, tested in a VM, and GRUB, on hardware, are not.
541        let warned = warnings(
542            ["sddm", "grub", "htop", "arch-server-setup"],
543            &|id| catalog.find(id),
544            &found,
545        );
546        assert_eq!(warned.len(), 1, "{warned:?}");
547        assert!(warned[0].starts_with("Arch Server Setup"), "{warned:?}");
548        // Whole-system entries count as critical by their risk.
549        assert!(found.critical(catalog.find("arch-server-setup").unwrap()));
550    }
551
552    #[test]
553    fn a_build_without_a_record_says_so() {
554        let found = evidence("", None);
555        assert!(found.summary().contains("No container record"));
556        let catalog = Catalog::builtin(false).unwrap();
557        assert_eq!(
558            found.assess(catalog.find("htop").unwrap(), &[]).level,
559            Level::NotYetValidated
560        );
561    }
562
563    #[test]
564    fn the_catalogs_own_record_reads_and_names_real_entries() {
565        let catalog = Catalog::builtin(false).unwrap();
566        let found = Evidence::load(catalog.root());
567        assert!(found.problems.is_empty(), "{:?}", found.problems);
568        assert!(!found.claims.stand_ins.is_empty());
569        for id in found
570            .claims
571            .stand_ins
572            .iter()
573            .flat_map(|group| group.entries.iter())
574            .chain(found.claims.critical.entries.iter())
575            .chain(found.claims.hardware.iter().map(|report| &report.entry))
576            .chain(found.claims.vm.iter().map(|report| &report.entry))
577        {
578            assert!(catalog.find(id).is_some(), "validation.toml names {id}");
579        }
580    }
581}