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    let text = std::fs::read_to_string(conf).ok()?;
317    Some(
318        text.lines()
319            .filter_map(|line| line.trim().strip_prefix('[')?.strip_suffix(']'))
320            .filter(|name| *name != "options")
321            .map(String::from)
322            .collect(),
323    )
324}
325
326/// The installed size of packages from the repository metadata, summed;
327/// None when the manager cannot say for this family.
328pub fn installed_size(manager: PackageManager, packages: &[String]) -> Option<u64> {
329    let names: Vec<&str> = packages
330        .iter()
331        .map(String::as_str)
332        .filter(|name| valid_package_name(name))
333        .collect();
334    if names.is_empty() {
335        return None;
336    }
337    let (program, mut args): (&str, Vec<&str>) = match manager {
338        PackageManager::Arch => ("pacman", vec!["-Si"]),
339        PackageManager::Debian => ("apt-cache", vec!["show", "--no-all-versions"]),
340        PackageManager::Fedora => (
341            "dnf",
342            vec![
343                "-C",
344                "-q",
345                "repoquery",
346                "--latest-limit=1",
347                "--qf",
348                "%{installsize}\n",
349            ],
350        ),
351        _ => return None,
352    };
353    args.extend(&names);
354    let output = Command::new(program)
355        .args(&args)
356        .env("LC_ALL", "C")
357        .stderr(std::process::Stdio::null())
358        .output()
359        .ok()?;
360    let text = String::from_utf8_lossy(&output.stdout);
361    parse_sizes(manager, &text)
362}
363
364/// The summed sizes in a package manager's answer.
365pub fn parse_sizes(manager: PackageManager, text: &str) -> Option<u64> {
366    let sizes: Vec<u64> = match manager {
367        // "Installed Size  : 4388.95 KiB"
368        PackageManager::Arch => text
369            .lines()
370            .filter_map(|line| line.strip_prefix("Installed Size"))
371            .filter_map(|rest| {
372                let mut words = rest.trim_start_matches([' ', ':']).split_whitespace();
373                let number: f64 = words.next()?.parse().ok()?;
374                let unit = match words.next()? {
375                    "B" => 1.0,
376                    "KiB" => 1024.0,
377                    "MiB" => 1024.0 * 1024.0,
378                    "GiB" => 1024.0 * 1024.0 * 1024.0,
379                    _ => return None,
380                };
381                Some((number * unit) as u64)
382            })
383            .collect(),
384        // "Installed-Size: 1234", in KiB
385        PackageManager::Debian => text
386            .lines()
387            .filter_map(|line| line.strip_prefix("Installed-Size:"))
388            .filter_map(|rest| rest.trim().parse::<u64>().ok())
389            .map(|kib| kib * 1024)
390            .collect(),
391        // One number of bytes per line
392        PackageManager::Fedora => text
393            .lines()
394            .filter_map(|line| line.trim().parse::<u64>().ok())
395            .collect(),
396        _ => Vec::new(),
397    };
398    (!sizes.is_empty()).then(|| sizes.iter().sum())
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::plan_with;
405    use helixforge_core::{Action, Needs, PackageNames};
406
407    fn arch() -> LinuxInfo {
408        LinuxInfo {
409            id: "garuda".into(),
410            pretty_name: "Garuda Linux".into(),
411            id_like: vec!["arch".into()],
412            manager: Some(PackageManager::Arch),
413        }
414    }
415
416    fn package(id: &str, arch: &str, conflicts: &[&str], needs: Needs) -> Entry {
417        Entry {
418            id: id.into(),
419            name: id.into(),
420            description: String::new(),
421            flags: "I".into(),
422            multi_select: true,
423            writes: Vec::new(),
424            action: Action::Package {
425                binary: None,
426                packages: PackageNames {
427                    arch: Some(arch.into()),
428                    ..Default::default()
429                },
430                aur: None,
431                flatpak: None,
432                conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
433                removable: true,
434            },
435            detect: None,
436            needs,
437        }
438    }
439
440    fn facts_of<'a>(
441        gpus: &'a dyn Fn() -> Vec<String>,
442        repositories: &'a dyn Fn() -> Option<Vec<String>>,
443        sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
444        present: &'a dyn Fn(&str) -> bool,
445        free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
446    ) -> Facts<'a> {
447        Facts {
448            machine: Machine {
449                holds: &|_| false,
450                on_path: &|_| false,
451            },
452            present,
453            free_bytes,
454            gpus,
455            repositories,
456            sizes,
457            active_runs: &Vec::new,
458        }
459    }
460
461    const GIB: u64 = 1 << 30;
462
463    fn run(entries: &[&Entry], facts: &Facts) -> Vec<Finding> {
464        let planned =
465            plan_with(entries.iter().copied(), &arch(), &|_| None, facts.machine).unwrap();
466        let lookup = |id: &str| entries.iter().copied().find(|entry| entry.id == id);
467        check(&planned, &arch(), &lookup, facts)
468    }
469
470    #[test]
471    fn entries_that_replace_each_other_cannot_be_queued_together() {
472        let wine = package("wine", "wine", &["wine-staging"], Needs::default());
473        let staging = package("wine-staging", "wine-staging", &["wine"], Needs::default());
474        let meld = package("meld", "meld", &[], Needs::default());
475        let plenty = |_: &Path| Some(100 * GIB);
476        let facts = facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty);
477        let found = run(&[&wine, &meld, &staging], &facts);
478        assert_eq!(
479            found,
480            [Finding::blocker(
481                "wine and wine-staging replace each other; queue only one".into()
482            )]
483        );
484        assert!(blocked(&found));
485        assert!(run(&[&wine, &meld], &facts).is_empty());
486    }
487
488    #[test]
489    fn missing_tools_and_repositories_block_and_hardware_warns() {
490        let steam = package(
491            "steam",
492            "steam",
493            &[],
494            Needs {
495                repositories: vec!["multilib".into()],
496                ..Default::default()
497            },
498        );
499        let nvidia = package(
500            "nvidia",
501            "nvidia-dkms",
502            &[],
503            Needs {
504                gpu: vec!["nvidia".into()],
505                ..Default::default()
506            },
507        );
508        let plenty = |_: &Path| Some(100 * GIB);
509        let intel = || vec!["intel".to_string()];
510        let core_only = || Some(vec!["core".to_string(), "extra".to_string()]);
511        let no_pacman = |tool: &str| tool != "pacman";
512        let facts = facts_of(&intel, &core_only, &|_, _| None, &no_pacman, &plenty);
513        let found = run(&[&steam, &nvidia], &facts);
514        let lines: Vec<String> = found.iter().map(Finding::line).collect();
515        assert_eq!(
516            lines,
517            [
518                "Blocked: pacman is needed and is not installed",
519                "Blocked: steam needs the [multilib] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
520                "Warning: nvidia is for nvidia graphics, and this machine has intel",
521            ]
522        );
523        // With multilib on and an Nvidia card, nothing to say.
524        let both = || vec!["intel".to_string(), "nvidia".to_string()];
525        let multilib = || Some(vec!["core".to_string(), "multilib".to_string()]);
526        let facts = facts_of(&both, &multilib, &|_, _| None, &|_| true, &plenty);
527        assert!(run(&[&steam, &nvidia], &facts).is_empty());
528    }
529
530    #[test]
531    fn the_disk_must_have_room_for_the_packages() {
532        let big = package(
533            "resolve",
534            "davinci-resolve",
535            &[],
536            Needs {
537                space_mb: Some(4096),
538                ..Default::default()
539            },
540        );
541        let sized = |_: PackageManager, names: &[String]| Some(names.len() as u64 * GIB);
542        let tight = |_: &Path| Some(3 * GIB);
543        let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &tight);
544        let found = run(&[&big], &facts);
545        assert_eq!(
546            found[0].line(),
547            "Blocked: the plan needs about 5.0 GiB and the root file system has 3.0 GiB free"
548        );
549        let close = |_: &Path| Some(5 * GIB + GIB / 2);
550        let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &close);
551        assert_eq!(
552            found_severity(&run(&[&big], &facts)),
553            Some(Severity::Warning)
554        );
555        let roomy = |_: &Path| Some(50 * GIB);
556        let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &roomy);
557        assert!(run(&[&big], &facts).is_empty());
558    }
559
560    #[test]
561    fn another_run_still_going_blocks() {
562        let meld = package("meld", "meld", &[], Needs::default());
563        let plenty = |_: &Path| Some(100 * GIB);
564        let busy = || vec!["Steam, Lutris".to_string()];
565        let facts = Facts {
566            active_runs: &busy,
567            ..facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty)
568        };
569        assert_eq!(
570            run(&[&meld], &facts)[0].line(),
571            "Blocked: another HelixForge run is still going (Steam, Lutris); wait for it to finish"
572        );
573    }
574
575    fn found_severity(found: &[Finding]) -> Option<Severity> {
576        found.first().map(|finding| finding.severity)
577    }
578
579    #[test]
580    fn machine_facts_are_read() {
581        let devices = tempfile::tempdir().unwrap();
582        for (slot, class, vendor) in [
583            ("0000:00:02.0", "0x030000", "0x8086"),
584            ("0000:01:00.0", "0x030200", "0x10de"),
585            ("0000:00:1f.3", "0x040300", "0x8086"),
586        ] {
587            let device = devices.path().join(slot);
588            std::fs::create_dir(&device).unwrap();
589            std::fs::write(device.join("class"), format!("{class}\n")).unwrap();
590            std::fs::write(device.join("vendor"), format!("{vendor}\n")).unwrap();
591        }
592        assert_eq!(gpus_in(devices.path()), ["intel", "nvidia"]);
593        let conf = devices.path().join("pacman.conf");
594        std::fs::write(
595            &conf,
596            "[options]\nColor\n[core]\nInclude = x\n#[multilib]\n#Include = x\n[chaotic-aur]\n",
597        )
598        .unwrap();
599        assert_eq!(pacman_repositories(&conf).unwrap(), ["core", "chaotic-aur"]);
600        assert_eq!(
601            parse_sizes(
602                PackageManager::Arch,
603                "Name : a\nInstalled Size  : 4388.95 KiB\nName : b\nInstalled Size  : 1.50 MiB\n"
604            ),
605            Some(4494284 + 1572864)
606        );
607        assert_eq!(
608            parse_sizes(PackageManager::Debian, "Package: a\nInstalled-Size: 100\n"),
609            Some(102400)
610        );
611        assert_eq!(
612            parse_sizes(PackageManager::Fedora, "2048\n4096\n"),
613            Some(6144)
614        );
615        assert_eq!(
616            parse_sizes(PackageManager::Arch, "error: package not found\n"),
617            None
618        );
619        assert!(free_bytes(Path::new("/")).is_some_and(|free| free > 0));
620    }
621}