Skip to main content

edera_check/recorders/postinstall/
system.rs

1use anyhow::{Result, bail};
2use async_trait::async_trait;
3use flate2::read::GzDecoder;
4use futures::{FutureExt, future::join_all};
5use procfs::Current;
6use std::{
7    io::Read,
8    path::{Path, PathBuf},
9    process::Command,
10};
11
12use crate::helpers::{
13    CheckGroup, CheckGroupCategory, CheckGroupResult, CheckResult,
14    CheckResultValue::{Errored, Failed, Passed, Skipped},
15    host_executor::HostNamespaceExecutor,
16};
17
18const GROUP_IDENTIFIER: &str = "sysinfo";
19const NAME: &str = "Postinstall System Info Recorder";
20
21pub struct SystemRecorder {
22    host_executor: HostNamespaceExecutor,
23}
24
25impl SystemRecorder {
26    pub fn new(host_executor: HostNamespaceExecutor) -> Self {
27        SystemRecorder { host_executor }
28    }
29
30    /// Run all the recorders asynchronously, then
31    /// join and collect the results.
32    pub async fn run_all(&self) -> CheckGroupResult {
33        let results = join_all([
34            self.record_lspci().boxed(),
35            self.record_dmidecode().boxed(),
36            self.record_cpuinfo().boxed(),
37            self.record_cmdline().boxed(),
38            self.record_hv_console().boxed(),
39            self.record_hv_debug_info().boxed(),
40            self.record_daemon_logs().boxed(),
41            self.record_cri_logs().boxed(),
42            self.record_storage_logs().boxed(),
43            self.record_kubelet_logs().boxed(),
44            self.record_network_logs().boxed(),
45            self.record_containerd_logs().boxed(),
46            self.record_grub_cfg().boxed(),
47            self.record_kernel_cfg().boxed(),
48            self.record_xen_capabilities().boxed(),
49            self.record_loaded_modules().boxed(),
50        ])
51        .await;
52
53        let mut group_result = Passed;
54        for res in results.iter() {
55            // Set group result to Failed if we failed and aren't already in an Errored state
56            if !matches!(group_result, Errored(_)) && matches!(res.result, Failed(_)) {
57                group_result = Failed(String::from("group failed"));
58            }
59
60            if matches!(res.result, Errored(_)) {
61                group_result = Errored(String::from("group errored"));
62            }
63        }
64
65        CheckGroupResult {
66            name: NAME.to_string(),
67            result: group_result,
68            results,
69        }
70    }
71
72    /// Runs the given command + args in host namespaces and captures the results.
73    async fn run_tool(&self, tool: &str) -> CheckResult {
74        let name = format!("Captured {tool}");
75        let mut tool_args: Vec<String> = tool.split(" ").map(|s| s.to_string()).collect();
76        let cmd = tool_args.remove(0);
77
78        let output = match self
79            .host_executor
80            .spawn_in_host_ns(async move { Command::new(cmd).args(tool_args).output() })
81            .await
82        {
83            Ok(output) => output,
84            Err(e) => return CheckResult::new(&name, Skipped(e.to_string())),
85        };
86
87        let output = match output {
88            Ok(output) => output,
89            Err(e) => return CheckResult::new(&name, Skipped(e.to_string())),
90        };
91
92        if !output.status.success() {
93            let error_message = String::from_utf8_lossy(&output.stderr);
94            return CheckResult::new(&name, Skipped(error_message.to_string()));
95        }
96
97        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
98        CheckResult::new_with_output(&name, Passed, Some(stdout))
99    }
100
101    /// Captures the content of a given file on the host.
102    async fn record_file(&self, file: &Path) -> Option<CheckResult> {
103        let local_file = file.to_path_buf();
104
105        self.host_executor
106            .spawn_in_host_ns(async move {
107                if !local_file.exists() {
108                    return None;
109                }
110                let name = format!("Captured {}", local_file.display());
111
112                let bytes = match tokio::fs::read(&local_file).await {
113                    Ok(b) => b,
114                    Err(e) => {
115                        return Some(CheckResult::new(
116                            &name,
117                            Errored(format!("failed to read {}: {e}", local_file.display())),
118                        ));
119                    }
120                };
121
122                let content = if bytes.starts_with(&[0x1f, 0x8b]) {
123                    // Gzip magic bytes detected
124                    let mut decoder = GzDecoder::new(&bytes[..]);
125                    let mut s = String::new();
126                    decoder.read_to_string(&mut s).map(|_| s)
127                } else {
128                    String::from_utf8(bytes)
129                        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
130                };
131
132                match content {
133                    Ok(c) => Some(CheckResult::new_with_output(&name, Passed, Some(c))),
134                    Err(e) => Some(CheckResult::new(
135                        &name,
136                        Errored(format!("failed to decode {}: {e}", local_file.display())),
137                    )),
138                }
139            })
140            .await
141            .unwrap_or_else(|_| panic!("could not record {}", file.display()))
142    }
143
144    /// Records verbose PCI device listing.
145    ///
146    /// Manual equivalent:
147    /// ```sh
148    /// lspci -vvv
149    /// ```
150    pub async fn record_lspci(&self) -> CheckResult {
151        self.run_tool("lspci -vvv").await
152    }
153
154    /// Records DMI/SMBIOS hardware information (BIOS, board, chassis, CPU, memory).
155    ///
156    /// Manual equivalent:
157    /// ```sh
158    /// dmidecode
159    /// ```
160    pub async fn record_dmidecode(&self) -> CheckResult {
161        self.run_tool("dmidecode").await
162    }
163
164    /// Records the Xen hypervisor console log via the protect-ctl tool.
165    ///
166    /// Manual equivalent:
167    /// ```sh
168    /// protect-ctl host hv-console
169    /// ```
170    pub async fn record_hv_console(&self) -> CheckResult {
171        self.run_tool("protect-ctl host hv-console").await
172    }
173
174    /// Records the Xen hypervisor debug state via the protect-ctl tool.
175    ///
176    /// Manual equivalent:
177    /// ```sh
178    /// protect-ctl host hv-debug-info
179    /// ```
180    pub async fn record_hv_debug_info(&self) -> CheckResult {
181        self.run_tool("protect-ctl host hv-debug-info").await
182    }
183
184    /// Records the `protect-daemon` journalctl log.
185    ///
186    /// Manual equivalent:
187    /// ```sh
188    /// journalctl -u protect-daemon
189    /// ```
190    pub async fn record_daemon_logs(&self) -> CheckResult {
191        self.run_tool("journalctl -u protect-daemon").await
192    }
193
194    /// Records the `protect-cri` journalctl log.
195    ///
196    /// Manual equivalent:
197    /// ```sh
198    /// journalctl -u protect-cri
199    /// ```
200    pub async fn record_cri_logs(&self) -> CheckResult {
201        self.run_tool("journalctl -u protect-cri").await
202    }
203
204    /// Records the `protect-storage` journalctl log.
205    ///
206    /// Manual equivalent:
207    /// ```sh
208    /// journalctl -u protect-storage
209    /// ```
210    pub async fn record_storage_logs(&self) -> CheckResult {
211        self.run_tool("journalctl -u protect-storage").await
212    }
213
214    /// Records the `protect-network` journalctl log.
215    ///
216    /// Manual equivalent:
217    /// ```sh
218    /// journalctl -u protect-storage
219    /// ```
220    pub async fn record_network_logs(&self) -> CheckResult {
221        self.run_tool("journalctl -u protect-network").await
222    }
223
224    /// Records the `containerd` journalctl log.
225    ///
226    /// Manual equivalent:
227    /// ```sh
228    /// journalctl -u containerd
229    /// ```
230    pub async fn record_containerd_logs(&self) -> CheckResult {
231        self.run_tool("journalctl -u containerd").await
232    }
233
234    /// Records the `kubelet` journalctl log.
235    ///
236    /// Manual equivalent:
237    /// ```sh
238    /// journalctl -u kubelet
239    /// ```
240    pub async fn record_kubelet_logs(&self) -> CheckResult {
241        self.run_tool("journalctl -u kubelet").await
242    }
243
244    /// Records CPU hardware details and feature flags.
245    ///
246    /// Manual equivalent:
247    /// ```sh
248    /// cat /proc/cpuinfo
249    /// ```
250    pub async fn record_cpuinfo(&self) -> CheckResult {
251        self.record_file(PathBuf::from("/proc/cpuinfo").as_ref())
252            .await
253            .expect("/proc/cpuinfo not found")
254    }
255
256    /// Records the kernel command line used to boot the running kernel.
257    ///
258    /// Manual equivalent:
259    /// ```sh
260    /// cat /proc/cmdline
261    /// ```
262    pub async fn record_cmdline(&self) -> CheckResult {
263        self.record_file(PathBuf::from("/proc/cmdline").as_ref())
264            .await
265            .expect("/proc/cmdline not found")
266    }
267
268    /// Records the Xen hypervisor capability string.
269    ///
270    /// Manual equivalent:
271    /// ```sh
272    /// cat /sys/hypervisor/properties/capabilities
273    /// ```
274    pub async fn record_xen_capabilities(&self) -> CheckResult {
275        self.record_file(PathBuf::from("/sys/hypervisor/properties/capabilities").as_ref())
276            .await
277            .expect("/sys/hypervisor/properties/capabilities not found")
278    }
279
280    /// Records the GRUB bootloader configuration. Checks `/boot/grub2/grub.cfg` first,
281    /// falling back to `/boot/grub/grub.cfg`.
282    ///
283    /// Manual equivalent:
284    /// ```sh
285    /// cat /boot/grub2/grub.cfg || cat /boot/grub/grub.cfg
286    /// ```
287    pub async fn record_grub_cfg(&self) -> CheckResult {
288        // prefer grub2 path, since if both are present for any reason,
289        // that is likely to be the "correct" one.
290        let files = ["/boot/grub2/grub.cfg", "/boot/grub/grub.cfg"];
291
292        for file in files {
293            if let Some(result) = self.record_file(&PathBuf::from(file)).await {
294                return result;
295            }
296        }
297        CheckResult::new(
298            "Record grub config",
299            Skipped(format!("no grub config found in {:?}", files)),
300        )
301    }
302
303    /// Records the kernel build configuration. Checks `/proc/config.gz` first (decompressing
304    /// if needed), falling back to `/boot/config-$(uname -r)`.
305    ///
306    /// Manual equivalent:
307    /// ```sh
308    /// zcat /proc/config.gz || cat /boot/config-$(uname -r)
309    /// ```
310    pub async fn record_kernel_cfg(&self) -> CheckResult {
311        let name = "Record kernel config";
312        // Get kernel version
313        //
314        let Ok(kver) = self.current_kernel_version().await else {
315            return CheckResult::new(name, Errored("failed to find kernel version".to_string()));
316        };
317
318        let files = ["/proc/config.gz", &format!("boot/config-{kver}")];
319
320        for file in files {
321            if let Some(result) = self.record_file(&PathBuf::from(file)).await {
322                return result;
323            }
324        }
325        CheckResult::new(
326            name,
327            Skipped(format!("no kernel config found in {:?}", files)),
328        )
329    }
330
331    /// Records the list of currently loaded kernel modules.
332    ///
333    /// Manual equivalent:
334    /// ```sh
335    /// cut -d' ' -f1 /proc/modules
336    /// ```
337    pub async fn record_loaded_modules(&self) -> CheckResult {
338        let name = "Record current host kernel loaded modules";
339        match self
340            .host_executor
341            .spawn_in_host_ns(async move { procfs::KernelModules::current() })
342            .await
343        {
344            Ok(Ok(list)) => CheckResult::new_with_output(
345                name,
346                Passed,
347                Some(list.0.into_keys().collect::<Vec<_>>().join("\n")),
348            ),
349            Ok(Err(e)) => CheckResult::new(name, Errored(e.to_string())),
350            Err(e) => CheckResult::new(name, Skipped(e.to_string())),
351        }
352    }
353
354    async fn current_kernel_version(&self) -> Result<String> {
355        self.host_executor
356            .spawn_in_host_ns(async {
357                let output = Command::new("uname")
358                    .arg("-r")
359                    .output()
360                    .expect("Failed to execute command");
361
362                if !output.status.success() {
363                    let error_message = String::from_utf8_lossy(&output.stderr);
364                    bail!("{}", error_message);
365                }
366
367                Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
368            })
369            .await?
370    }
371}
372
373#[async_trait]
374impl CheckGroup for SystemRecorder {
375    fn id(&self) -> &str {
376        GROUP_IDENTIFIER
377    }
378
379    fn name(&self) -> &str {
380        NAME
381    }
382
383    fn description(&self) -> &str {
384        "Record postinstall system information"
385    }
386
387    async fn run(&self) -> CheckGroupResult {
388        self.run_all().await
389    }
390
391    fn category(&self) -> CheckGroupCategory {
392        CheckGroupCategory::Advisory
393    }
394}