Skip to main content

helixforge_engine/
preflight.rs

1//! Checks made before a plan runs: what it needs that is missing, entries in
2//! it that conflict with each other or with what is installed, repositories
3//! it needs, the graphics hardware it is for, and whether the disk has room.
4//! A blocker stops the run; a warning is shown with the confirmation.
5
6use crate::{Machine, PackageState, Plan, Probe, Step, missing_requirements, package_state};
7use helixforge_core::{Entry, PackageManager, valid_package_name};
8use helixforge_platform::LinuxInfo;
9use std::{path::Path, process::Command};
10
11/// How serious a finding is.
12#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
13pub enum Severity {
14    Warning,
15    Blocker,
16}
17
18/// One thing found before a run.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Finding {
21    pub severity: Severity,
22    pub message: String,
23}
24
25impl Finding {
26    fn blocker(message: String) -> Self {
27        Finding {
28            severity: Severity::Blocker,
29            message,
30        }
31    }
32
33    fn warning(message: String) -> Self {
34        Finding {
35            severity: Severity::Warning,
36            message,
37        }
38    }
39
40    /// The finding as one line, with its severity.
41    pub fn line(&self) -> String {
42        match self.severity {
43            Severity::Blocker => format!("Blocked: {}", self.message),
44            Severity::Warning => format!("Warning: {}", self.message),
45        }
46    }
47}
48
49/// Whether any finding stops the run.
50pub fn blocked(findings: &[Finding]) -> bool {
51    findings
52        .iter()
53        .any(|finding| finding.severity == Severity::Blocker)
54}
55
56/// What the checks ask the machine; tests answer instead.
57pub struct Facts<'a> {
58    /// Package questions, as planning asks them.
59    pub machine: Machine<'a>,
60    /// Whether a command, or an absolute path, is present.
61    pub present: &'a dyn Fn(&str) -> bool,
62    /// Free bytes on the file system holding a path.
63    pub free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
64    /// The graphics vendors found (nvidia, amd, intel).
65    pub gpus: &'a dyn Fn() -> Vec<String>,
66    /// The enabled pacman repositories; None off Arch or when unreadable.
67    pub repositories: &'a dyn Fn() -> Option<Vec<String>>,
68    /// The installed size in bytes of packages not yet installed, when the
69    /// package manager can say.
70    pub sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
71    /// Other HelixForge runs still going, by their entries' names.
72    pub active_runs: &'a dyn Fn() -> Vec<String>,
73}
74
75impl Facts<'static> {
76    /// This machine.
77    pub fn real() -> Self {
78        Facts {
79            machine: Machine::real(),
80            present: &crate::requirement_present,
81            free_bytes: &free_bytes,
82            gpus: &gpus,
83            repositories: &|| pacman_repositories(Path::new("/etc/pacman.conf")),
84            sizes: &installed_size,
85            active_runs: &crate::history::active,
86        }
87    }
88}
89
90/// Less free space than this left after a run earns a warning.
91const SPARE: u64 = 1 << 30;
92
93/// Check a plan before it runs. `lookup` finds the entry behind each step.
94pub fn check<'a>(
95    plan: &Plan,
96    platform: &LinuxInfo,
97    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
98    facts: &Facts,
99) -> Vec<Finding> {
100    let mut findings = Vec::new();
101    // One run at a time, so package managers are never driven twice at once.
102    if !plan.steps.is_empty() {
103        for names in (facts.active_runs)() {
104            findings.push(Finding::blocker(format!(
105                "another HelixForge run is still going ({names}); wait for it to finish"
106            )));
107        }
108    }
109    for missing in missing_requirements(plan, facts.present) {
110        findings.push(Finding::blocker(format!(
111            "{missing} is needed and is not installed"
112        )));
113    }
114    let entries: Vec<&Entry> = plan
115        .steps
116        .iter()
117        .filter(|step| !matches!(step, Step::Update { .. }))
118        .filter(|step| !matches!(step, Step::Package { removal: true, .. }))
119        .filter_map(|step| lookup(step.id()))
120        .collect();
121    conflicts(&entries, platform, facts, &mut findings);
122    needs(&entries, platform, facts, &mut findings);
123    space(plan, &entries, platform, facts, &mut findings);
124    findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
125    findings
126}
127
128/// The packages an entry installs on this machine: its family's and, on
129/// Arch, its AUR package.
130fn installs(entry: &Entry, platform: &LinuxInfo) -> Vec<String> {
131    let Some(identity) = entry.identity() else {
132        return Vec::new();
133    };
134    let mut names = Vec::new();
135    if let Some(manager) = platform.manager {
136        names.extend(identity.packages.for_manager(manager).map(String::from));
137        if manager == PackageManager::Arch {
138            names.extend(identity.aur.map(String::from));
139        }
140    }
141    names
142}
143
144/// Entries in the plan that replace each other, and entries that replace an
145/// installed package.
146fn conflicts(entries: &[&Entry], platform: &LinuxInfo, facts: &Facts, into: &mut Vec<Finding>) {
147    for (index, entry) in entries.iter().enumerate() {
148        let Some(identity) = entry.identity() else {
149            continue;
150        };
151        for other in &entries[index + 1..] {
152            let theirs = installs(other, platform);
153            let ours = installs(entry, platform);
154            let clash = identity.conflicts.iter().any(|name| theirs.contains(name))
155                || other
156                    .identity()
157                    .is_some_and(|their| their.conflicts.iter().any(|name| ours.contains(name)));
158            if clash {
159                into.push(Finding::blocker(format!(
160                    "{} and {} replace each other; queue only one",
161                    entry.name, other.name
162                )));
163            }
164        }
165        if let Some(PackageState::Absent { replaces }) =
166            package_state(entry, platform, facts.machine)
167            && !replaces.is_empty()
168        {
169            into.push(Finding::warning(format!(
170                "{} replaces {}, which is installed; the package manager asks before removing it",
171                entry.name,
172                replaces.join(", ")
173            )));
174        }
175    }
176}
177
178/// Repositories and graphics hardware the entries need.
179fn needs(entries: &[&Entry], platform: &LinuxInfo, facts: &Facts, into: &mut Vec<Finding>) {
180    let mut enabled = None;
181    let mut found = None;
182    for entry in entries {
183        if platform.manager == Some(PackageManager::Arch) && !entry.needs.repositories.is_empty() {
184            let enabled = enabled.get_or_insert_with(|| (facts.repositories)());
185            if let Some(enabled) = enabled {
186                for repository in &entry.needs.repositories {
187                    if !enabled.contains(repository) {
188                        into.push(Finding::blocker(format!(
189                            "{} needs the [{repository}] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
190                            entry.name
191                        )));
192                    }
193                }
194            }
195        }
196        if !entry.needs.gpu.is_empty() {
197            let found = found.get_or_insert_with(|| (facts.gpus)());
198            if !entry.needs.gpu.iter().any(|vendor| found.contains(vendor)) {
199                into.push(Finding::warning(format!(
200                    "{} is for {} graphics, and this machine has {}",
201                    entry.name,
202                    entry.needs.gpu.join(" or "),
203                    if found.is_empty() {
204                        "none that HelixForge can see".to_string()
205                    } else {
206                        found.join(" and ")
207                    }
208                )));
209            }
210        }
211    }
212}
213
214/// Whether the root file system has room for the packages and what the
215/// entries say they need.
216fn space(
217    plan: &Plan,
218    entries: &[&Entry],
219    platform: &LinuxInfo,
220    facts: &Facts,
221    into: &mut Vec<Finding>,
222) {
223    if plan.steps.is_empty() {
224        return;
225    }
226    let Some(free) = (facts.free_bytes)(Path::new("/")) else {
227        return;
228    };
229    let mut need: u64 = entries
230        .iter()
231        .filter_map(|entry| entry.needs.space_mb)
232        .map(|megabytes| megabytes << 20)
233        .sum();
234    // Native packages the plan installs, as far as the manager can size them.
235    let mut flatpak = false;
236    let mut packages = Vec::new();
237    for step in &plan.steps {
238        if let Step::Package {
239            sources,
240            removal: false,
241            ..
242        } = step
243        {
244            match sources.first().and_then(|source| source.probe.as_ref()) {
245                Some(Probe::Package { name, .. }) => packages.push(name.clone()),
246                Some(Probe::Flatpak(_)) => flatpak = true,
247                None => {}
248            }
249        }
250    }
251    let sized = match platform.manager {
252        Some(manager) if !packages.is_empty() => (facts.sizes)(manager, &packages),
253        _ => None,
254    };
255    need += sized.unwrap_or(0);
256    let shown = |bytes: u64| format!("{:.1} GiB", bytes as f64 / f64::from(1u32 << 30));
257    if need > 0 && free < need {
258        into.push(Finding::blocker(format!(
259            "the plan needs about {} and the root file system has {} free",
260            shown(need),
261            shown(free)
262        )));
263    } else if free < need + SPARE {
264        into.push(Finding::warning(format!(
265            "the root file system has {} free; after this plan less than {} would be left",
266            shown(free),
267            shown(SPARE)
268        )));
269    } else if flatpak && free < 4 * SPARE {
270        into.push(Finding::warning(format!(
271            "the root file system has {} free, and a Flatpak with its runtime can take several GiB",
272            shown(free)
273        )));
274    }
275}
276
277/// Free bytes for an unprivileged user on the file system holding `path`.
278pub fn free_bytes(path: &Path) -> Option<u64> {
279    let stats = nix::sys::statvfs::statvfs(path).ok()?;
280    Some(stats.blocks_available() as u64 * stats.fragment_size() as u64)
281}
282
283/// The graphics vendors among this machine's PCI display controllers.
284pub fn gpus() -> Vec<String> {
285    gpus_in(Path::new("/sys/bus/pci/devices"))
286}
287
288/// `gpus` under another sysfs directory, for tests.
289pub fn gpus_in(devices: &Path) -> Vec<String> {
290    let mut found = Vec::new();
291    let Ok(listing) = std::fs::read_dir(devices) else {
292        return found;
293    };
294    for device in listing.flatten() {
295        let read =
296            |name: &str| std::fs::read_to_string(device.path().join(name)).unwrap_or_default();
297        if !read("class").trim().starts_with("0x03") {
298            continue;
299        }
300        let vendor = match read("vendor").trim() {
301            "0x10de" => "nvidia",
302            "0x1002" => "amd",
303            "0x8086" => "intel",
304            _ => continue,
305        };
306        if !found.iter().any(|known| known == vendor) {
307            found.push(vendor.to_string());
308        }
309    }
310    found.sort();
311    found
312}
313
314/// The repositories a pacman.conf enables: its sections other than options.
315pub fn pacman_repositories(conf: &Path) -> Option<Vec<String>> {
316    Some(repositories_in(&std::fs::read_to_string(conf).ok()?))
317}
318
319/// The repositories a pacman.conf's text enables.
320pub fn repositories_in(text: &str) -> Vec<String> {
321    text.lines()
322        .filter_map(|line| line.trim().strip_prefix('[')?.strip_suffix(']'))
323        .filter(|name| *name != "options")
324        .map(String::from)
325        .collect()
326}
327
328/// The installed size of packages from the repository metadata, summed;
329/// None when the manager cannot say for this family.
330pub fn installed_size(manager: PackageManager, packages: &[String]) -> Option<u64> {
331    let names: Vec<&str> = packages
332        .iter()
333        .map(String::as_str)
334        .filter(|name| valid_package_name(name))
335        .collect();
336    if names.is_empty() {
337        return None;
338    }
339    let (program, mut args): (&str, Vec<&str>) = match manager {
340        PackageManager::Arch => ("pacman", vec!["-Si"]),
341        PackageManager::Debian => ("apt-cache", vec!["show", "--no-all-versions"]),
342        PackageManager::Fedora => (
343            "dnf",
344            vec![
345                "-C",
346                "-q",
347                "repoquery",
348                "--latest-limit=1",
349                "--qf",
350                "%{installsize}\n",
351            ],
352        ),
353        _ => return None,
354    };
355    args.extend(&names);
356    let output = Command::new(program)
357        .args(&args)
358        .env("LC_ALL", "C")
359        .stderr(std::process::Stdio::null())
360        .output()
361        .ok()?;
362    let text = String::from_utf8_lossy(&output.stdout);
363    parse_sizes(manager, &text)
364}
365
366/// The summed sizes in a package manager's answer.
367pub fn parse_sizes(manager: PackageManager, text: &str) -> Option<u64> {
368    let sizes: Vec<u64> = match manager {
369        // "Installed Size  : 4388.95 KiB"
370        PackageManager::Arch => text
371            .lines()
372            .filter_map(|line| line.strip_prefix("Installed Size"))
373            .filter_map(|rest| {
374                let mut words = rest.trim_start_matches([' ', ':']).split_whitespace();
375                let number: f64 = words.next()?.parse().ok()?;
376                let unit = match words.next()? {
377                    "B" => 1.0,
378                    "KiB" => 1024.0,
379                    "MiB" => 1024.0 * 1024.0,
380                    "GiB" => 1024.0 * 1024.0 * 1024.0,
381                    _ => return None,
382                };
383                Some((number * unit) as u64)
384            })
385            .collect(),
386        // "Installed-Size: 1234", in KiB
387        PackageManager::Debian => text
388            .lines()
389            .filter_map(|line| line.strip_prefix("Installed-Size:"))
390            .filter_map(|rest| rest.trim().parse::<u64>().ok())
391            .map(|kib| kib * 1024)
392            .collect(),
393        // One number of bytes per line
394        PackageManager::Fedora => text
395            .lines()
396            .filter_map(|line| line.trim().parse::<u64>().ok())
397            .collect(),
398        _ => Vec::new(),
399    };
400    (!sizes.is_empty()).then(|| sizes.iter().sum())
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::plan_with;
407    use helixforge_core::{Action, Needs, PackageNames};
408
409    fn arch() -> LinuxInfo {
410        LinuxInfo {
411            id: "garuda".into(),
412            pretty_name: "Garuda Linux".into(),
413            id_like: vec!["arch".into()],
414            manager: Some(PackageManager::Arch),
415        }
416    }
417
418    fn package(id: &str, arch: &str, conflicts: &[&str], needs: Needs) -> Entry {
419        Entry {
420            id: id.into(),
421            name: id.into(),
422            description: String::new(),
423            flags: "I".into(),
424            multi_select: true,
425            writes: Vec::new(),
426            action: Action::Package {
427                binary: None,
428                packages: PackageNames {
429                    arch: Some(arch.into()),
430                    ..Default::default()
431                },
432                aur: None,
433                flatpak: None,
434                conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
435                removable: true,
436            },
437            detect: None,
438            needs,
439            effects: Default::default(),
440        }
441    }
442
443    fn facts_of<'a>(
444        gpus: &'a dyn Fn() -> Vec<String>,
445        repositories: &'a dyn Fn() -> Option<Vec<String>>,
446        sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
447        present: &'a dyn Fn(&str) -> bool,
448        free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
449    ) -> Facts<'a> {
450        Facts {
451            machine: Machine {
452                holds: &|_| false,
453                on_path: &|_| false,
454            },
455            present,
456            free_bytes,
457            gpus,
458            repositories,
459            sizes,
460            active_runs: &Vec::new,
461        }
462    }
463
464    const GIB: u64 = 1 << 30;
465
466    fn run(entries: &[&Entry], facts: &Facts) -> Vec<Finding> {
467        let planned =
468            plan_with(entries.iter().copied(), &arch(), &|_| None, facts.machine).unwrap();
469        let lookup = |id: &str| entries.iter().copied().find(|entry| entry.id == id);
470        check(&planned, &arch(), &lookup, facts)
471    }
472
473    #[test]
474    fn entries_that_replace_each_other_cannot_be_queued_together() {
475        let wine = package("wine", "wine", &["wine-staging"], Needs::default());
476        let staging = package("wine-staging", "wine-staging", &["wine"], Needs::default());
477        let meld = package("meld", "meld", &[], Needs::default());
478        let plenty = |_: &Path| Some(100 * GIB);
479        let facts = facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty);
480        let found = run(&[&wine, &meld, &staging], &facts);
481        assert_eq!(
482            found,
483            [Finding::blocker(
484                "wine and wine-staging replace each other; queue only one".into()
485            )]
486        );
487        assert!(blocked(&found));
488        assert!(run(&[&wine, &meld], &facts).is_empty());
489    }
490
491    #[test]
492    fn missing_tools_and_repositories_block_and_hardware_warns() {
493        let steam = package(
494            "steam",
495            "steam",
496            &[],
497            Needs {
498                repositories: vec!["multilib".into()],
499                ..Default::default()
500            },
501        );
502        let nvidia = package(
503            "nvidia",
504            "nvidia-dkms",
505            &[],
506            Needs {
507                gpu: vec!["nvidia".into()],
508                ..Default::default()
509            },
510        );
511        let plenty = |_: &Path| Some(100 * GIB);
512        let intel = || vec!["intel".to_string()];
513        let core_only = || Some(vec!["core".to_string(), "extra".to_string()]);
514        let no_pacman = |tool: &str| tool != "pacman";
515        let facts = facts_of(&intel, &core_only, &|_, _| None, &no_pacman, &plenty);
516        let found = run(&[&steam, &nvidia], &facts);
517        let lines: Vec<String> = found.iter().map(Finding::line).collect();
518        assert_eq!(
519            lines,
520            [
521                "Blocked: pacman is needed and is not installed",
522                "Blocked: steam needs the [multilib] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
523                "Warning: nvidia is for nvidia graphics, and this machine has intel",
524            ]
525        );
526        // With multilib on and an Nvidia card, nothing to say.
527        let both = || vec!["intel".to_string(), "nvidia".to_string()];
528        let multilib = || Some(vec!["core".to_string(), "multilib".to_string()]);
529        let facts = facts_of(&both, &multilib, &|_, _| None, &|_| true, &plenty);
530        assert!(run(&[&steam, &nvidia], &facts).is_empty());
531    }
532
533    #[test]
534    fn the_disk_must_have_room_for_the_packages() {
535        let big = package(
536            "resolve",
537            "davinci-resolve",
538            &[],
539            Needs {
540                space_mb: Some(4096),
541                ..Default::default()
542            },
543        );
544        let sized = |_: PackageManager, names: &[String]| Some(names.len() as u64 * GIB);
545        let tight = |_: &Path| Some(3 * GIB);
546        let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &tight);
547        let found = run(&[&big], &facts);
548        assert_eq!(
549            found[0].line(),
550            "Blocked: the plan needs about 5.0 GiB and the root file system has 3.0 GiB free"
551        );
552        let close = |_: &Path| Some(5 * GIB + GIB / 2);
553        let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &close);
554        assert_eq!(
555            found_severity(&run(&[&big], &facts)),
556            Some(Severity::Warning)
557        );
558        let roomy = |_: &Path| Some(50 * GIB);
559        let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &roomy);
560        assert!(run(&[&big], &facts).is_empty());
561    }
562
563    #[test]
564    fn another_run_still_going_blocks() {
565        let meld = package("meld", "meld", &[], Needs::default());
566        let plenty = |_: &Path| Some(100 * GIB);
567        let busy = || vec!["Steam, Lutris".to_string()];
568        let facts = Facts {
569            active_runs: &busy,
570            ..facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty)
571        };
572        assert_eq!(
573            run(&[&meld], &facts)[0].line(),
574            "Blocked: another HelixForge run is still going (Steam, Lutris); wait for it to finish"
575        );
576    }
577
578    fn found_severity(found: &[Finding]) -> Option<Severity> {
579        found.first().map(|finding| finding.severity)
580    }
581
582    #[test]
583    fn machine_facts_are_read() {
584        let devices = tempfile::tempdir().unwrap();
585        for (slot, class, vendor) in [
586            ("0000:00:02.0", "0x030000", "0x8086"),
587            ("0000:01:00.0", "0x030200", "0x10de"),
588            ("0000:00:1f.3", "0x040300", "0x8086"),
589        ] {
590            let device = devices.path().join(slot);
591            std::fs::create_dir(&device).unwrap();
592            std::fs::write(device.join("class"), format!("{class}\n")).unwrap();
593            std::fs::write(device.join("vendor"), format!("{vendor}\n")).unwrap();
594        }
595        assert_eq!(gpus_in(devices.path()), ["intel", "nvidia"]);
596        let conf = devices.path().join("pacman.conf");
597        std::fs::write(
598            &conf,
599            "[options]\nColor\n[core]\nInclude = x\n#[multilib]\n#Include = x\n[chaotic-aur]\n",
600        )
601        .unwrap();
602        assert_eq!(pacman_repositories(&conf).unwrap(), ["core", "chaotic-aur"]);
603        assert_eq!(
604            parse_sizes(
605                PackageManager::Arch,
606                "Name : a\nInstalled Size  : 4388.95 KiB\nName : b\nInstalled Size  : 1.50 MiB\n"
607            ),
608            Some(4494284 + 1572864)
609        );
610        assert_eq!(
611            parse_sizes(PackageManager::Debian, "Package: a\nInstalled-Size: 100\n"),
612            Some(102400)
613        );
614        assert_eq!(
615            parse_sizes(PackageManager::Fedora, "2048\n4096\n"),
616            Some(6144)
617        );
618        assert_eq!(
619            parse_sizes(PackageManager::Arch, "error: package not found\n"),
620            None
621        );
622        assert!(free_bytes(Path::new("/")).is_some_and(|free| free > 0));
623    }
624}