Skip to main content

kernel_builder/
lib.rs

1pub mod boot;
2pub mod cli;
3pub mod consts;
4pub mod discovery;
5mod error;
6
7pub use cli::Args;
8pub use consts::KernelPaths;
9pub use discovery::VersionEntry;
10pub use error::BuilderErr;
11
12use crate::boot::BootManager;
13use crate::consts::MAKE_COMMAND;
14use dialoguer::{Confirm, Select, console::Term, theme::ColorfulTheme};
15use indicatif::{ProgressBar, ProgressStyle};
16use serde::Deserialize;
17use std::num::NonZeroUsize;
18use std::path::{Path, PathBuf};
19use tracing::info;
20
21#[derive(Debug, Clone)]
22pub struct BuildProgress {
23    steps: Vec<BuildStep>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum BuildStep {
28    ConfigLink,
29    SymlinkUpdate,
30    Menuconfig,
31    ConfigUpdate,
32    BuildKernel,
33    InstallKernel,
34    InstallModules,
35    GenerateInitramfs,
36    CleanupKernel,
37}
38
39impl BuildStep {
40    #[must_use]
41    pub fn label(&self) -> &'static str {
42        match self {
43            BuildStep::ConfigLink => "Linking kernel config",
44            BuildStep::SymlinkUpdate => "Updating linux symlink",
45            BuildStep::Menuconfig => "Running menuconfig",
46            BuildStep::ConfigUpdate => "Updating kernel config",
47            BuildStep::BuildKernel => "Building kernel",
48            BuildStep::InstallKernel => "Installing kernel",
49            BuildStep::InstallModules => "Installing modules",
50            BuildStep::GenerateInitramfs => "Generating initramfs",
51            BuildStep::CleanupKernel => "Cleaning up old kernels",
52        }
53    }
54}
55
56impl BuildProgress {
57    #[must_use]
58    pub fn new() -> Self {
59        Self { steps: Vec::new() }
60    }
61
62    pub fn add_step(&mut self, step: BuildStep) {
63        self.steps.push(step);
64    }
65
66    /// Create a progress bar for the build steps.
67    #[must_use]
68    pub fn create_progress_bar(&self) -> ProgressBar {
69        let count = self.steps.len();
70        if count == 0 {
71            return ProgressBar::hidden();
72        }
73
74        let pb = ProgressBar::new(count as u64);
75        pb.set_style(
76            ProgressStyle::with_template("{spinner:.cyan} [{bar:40.cyan/dim}] {pos}/{len} {msg}")
77                .expect("valid template")
78                .progress_chars("=>-"),
79        );
80        pb.set_message("Starting...");
81        pb
82    }
83}
84
85impl Default for BuildProgress {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91pub fn init_logging(verbose: bool) {
92    use tracing_subscriber::{EnvFilter, fmt, prelude::*};
93
94    if !verbose {
95        return;
96    }
97
98    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
99
100    tracing_subscriber::registry()
101        .with(fmt::layer().with_target(false).without_time())
102        .with(filter)
103        .init();
104}
105
106#[derive(Debug, Deserialize)]
107pub struct KBConfig {
108    #[serde(rename = "kernel")]
109    pub kernel_file_path: PathBuf,
110    #[serde(rename = "initramfs")]
111    pub initramfs_file_path: Option<PathBuf>,
112    #[serde(rename = "kernel-config")]
113    pub kernel_config_file_path: PathBuf,
114    #[serde(rename = "kernel-src")]
115    pub kernel_src: PathBuf,
116    #[serde(rename = "keep-last-kernel")]
117    pub keep_last_kernel: bool,
118    #[serde(rename = "last-kernel-suffix")]
119    pub last_kernel_suffix: Option<String>,
120    #[serde(rename = "cleanup-keep-count", default)]
121    pub cleanup_keep_count: Option<u32>,
122}
123
124impl KBConfig {
125    /// Validate the configuration.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if validation fails.
130    pub fn validate(&self) -> Result<(), BuilderErr> {
131        if !self.kernel_src.exists() {
132            return Err(BuilderErr::invalid_config(format!(
133                "Kernel source directory does not exist: {}",
134                self.kernel_src.display()
135            )));
136        }
137
138        if !self.kernel_src.is_dir() {
139            return Err(BuilderErr::invalid_config(format!(
140                "Kernel source path is not a directory: {}",
141                self.kernel_src.display()
142            )));
143        }
144
145        if self.kernel_file_path.parent().is_some_and(|p| !p.exists()) {
146            return Err(BuilderErr::invalid_config(format!(
147                "Kernel file parent directory does not exist: {}",
148                self.kernel_file_path.display()
149            )));
150        }
151
152        if let Some(ref initramfs) = self.initramfs_file_path {
153            if initramfs.parent().is_some_and(|p| !p.exists()) {
154                return Err(BuilderErr::invalid_config(format!(
155                    "Initramfs parent directory does not exist: {}",
156                    initramfs.display()
157                )));
158            }
159        }
160
161        Ok(())
162    }
163
164    #[must_use]
165    pub fn to_kernel_paths(&self) -> KernelPaths {
166        KernelPaths::new(
167            self.kernel_file_path.clone(),
168            self.initramfs_file_path.clone(),
169            self.kernel_config_file_path.clone(),
170            self.kernel_src.clone(),
171        )
172    }
173}
174
175#[derive(Debug)]
176pub struct KernelBuilder {
177    config: KBConfig,
178    versions: Vec<VersionEntry>,
179    boot_manager: BootManager,
180}
181
182impl KernelBuilder {
183    /// Create a new `KernelBuilder` instance.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if validation fails or version scanning fails.
188    pub fn new(config: KBConfig) -> Result<Self, BuilderErr> {
189        config.validate()?;
190
191        let versions = Self::scan_versions(&config.kernel_src)?;
192        let paths = config.to_kernel_paths();
193        let boot_manager = BootManager::new(
194            paths,
195            config.keep_last_kernel,
196            config.last_kernel_suffix.clone(),
197        );
198
199        Ok(Self {
200            config,
201            versions,
202            boot_manager,
203        })
204    }
205
206    #[must_use]
207    pub fn versions(&self) -> &[VersionEntry] {
208        &self.versions
209    }
210
211    fn scan_versions(kernel_src: &Path) -> Result<Vec<VersionEntry>, BuilderErr> {
212        let entries = std::fs::read_dir(kernel_src).map_err(|e| {
213            BuilderErr::discovery_error(format!(
214                "Failed to read kernel source directory {}: {e}",
215                kernel_src.display()
216            ))
217        })?;
218
219        let mut versions: Vec<VersionEntry> = Vec::new();
220        for dir in entries.flatten() {
221            let path = dir.path();
222            if path.is_dir() && !path.is_symlink() {
223                if let Some(entry) = VersionEntry::from_path(&path) {
224                    versions.push(entry);
225                }
226            }
227        }
228
229        versions.sort();
230        versions.reverse();
231
232        tracing::debug!("Found {} kernel versions", versions.len());
233        for v in &versions {
234            tracing::debug!("  - {v}");
235        }
236
237        Ok(versions)
238    }
239
240    /// Execute the kernel build workflow.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if any step of the build process fails.
245    pub fn build(&self, cli: &Args) -> Result<(), BuilderErr> {
246        let Some(version_entry) = self.prompt_for_kernel_version() else {
247            tracing::debug!("No kernel version selected, exiting");
248            return Ok(());
249        };
250
251        tracing::debug!("Selected kernel: {}", version_entry.version_string);
252
253        if !self.config.kernel_config_file_path.exists() {
254            return Err(BuilderErr::kernel_config_missing());
255        }
256
257        let mut progress = BuildProgress::new();
258        progress.add_step(BuildStep::ConfigLink);
259        progress.add_step(BuildStep::SymlinkUpdate);
260
261        if cli.menuconfig {
262            progress.add_step(BuildStep::Menuconfig);
263        }
264
265        if !cli.no_build {
266            progress.add_step(BuildStep::ConfigUpdate);
267            progress.add_step(BuildStep::BuildKernel);
268            progress.add_step(BuildStep::InstallKernel);
269        }
270
271        if !cli.no_modules {
272            progress.add_step(BuildStep::InstallModules);
273        }
274
275        #[cfg(feature = "dracut")]
276        if !cli.no_initramfs {
277            progress.add_step(BuildStep::GenerateInitramfs);
278        }
279
280        let mut pb = progress.create_progress_bar();
281
282        pb.set_message(BuildStep::ConfigLink.label());
283        pb.inc(1);
284        self.boot_manager.link_kernel_config(&version_entry.path)?;
285
286        pb.set_message(BuildStep::SymlinkUpdate.label());
287        pb.inc(1);
288        self.boot_manager.update_linux_symlink(&version_entry)?;
289
290        if cli.menuconfig {
291            pb.set_message(BuildStep::Menuconfig.label());
292            pb.inc(1);
293            // Run interactive menuconfig attached to the user's terminal.
294            use std::process::{Command, Stdio};
295
296            // Suspend the progress bar while the TUI runs to avoid output clashes.
297            let status = pb.suspend(|| {
298                Command::new(MAKE_COMMAND)
299                    .arg("menuconfig")
300                    .current_dir(&version_entry.path)
301                    .stdin(Stdio::inherit())
302                    .stdout(Stdio::inherit())
303                    .stderr(Stdio::inherit())
304                    .status()
305                    .map_err(|e| BuilderErr::CommandError(format!("Failed to run menuconfig: {e}")))
306            })?;
307
308            if !status.success() {
309                return Err(BuilderErr::MenuconfigError);
310            }
311
312            if !Self::confirm_prompt("Continue build process?")? {
313                pb.finish_with_message("Cancelled by user");
314                return Ok(());
315            }
316        }
317
318        if !cli.no_build {
319            pb.set_message(BuildStep::BuildKernel.label());
320            pb.inc(1);
321            self.build_kernel(&version_entry, cli.replace, &mut pb)?;
322        }
323
324        if !cli.no_modules && Self::confirm_prompt("Do you want to install kernel modules?")? {
325            pb.set_message(BuildStep::InstallModules.label());
326            pb.inc(1);
327            self.install_modules(&version_entry, &mut pb)?;
328        }
329
330        #[cfg(feature = "dracut")]
331        if !cli.no_initramfs
332            && Self::confirm_prompt("Do you want to generate initramfs with dracut?")?
333        {
334            pb.set_message(BuildStep::GenerateInitramfs.label());
335            pb.inc(1);
336            self.generate_initramfs(&version_entry, cli.replace, &mut pb)?;
337        }
338
339        if let Some(keep_count) = self.config.cleanup_keep_count {
340            self.cleanup_old_kernels(keep_count, &mut pb)?;
341        }
342
343        pb.finish_with_message("Build complete!");
344        Ok(())
345    }
346
347    fn cleanup_old_kernels(&self, keep_count: u32, pb: &mut ProgressBar) -> Result<(), BuilderErr> {
348        let current_kernel = self.boot_manager.get_current_kernel();
349        let current_version = current_kernel
350            .as_ref()
351            .and_then(|p| self.versions.iter().find(|v| v.path == *p));
352
353        let mut to_keep: Vec<&VersionEntry> = Vec::new();
354        if let Some(current) = current_version {
355            to_keep.push(current);
356        }
357
358        for v in &self.versions {
359            if to_keep.len() >= keep_count as usize {
360                break;
361            }
362            if !to_keep.contains(&v) {
363                to_keep.push(v);
364            }
365        }
366
367        let to_delete: Vec<&VersionEntry> = self
368            .versions
369            .iter()
370            .filter(|v| !to_keep.contains(v))
371            .collect();
372
373        if to_delete.is_empty() {
374            info!("No old kernels to clean up");
375            return Ok(());
376        }
377
378        pb.set_message(BuildStep::CleanupKernel.label());
379
380        for v in &to_delete {
381            info!("Cleaning up old kernel: {}", v.version_string);
382            self.boot_manager.remove_kernel(&v.path)?;
383        }
384
385        pb.inc(1);
386        info!("Cleaned up {} old kernel(s)", to_delete.len());
387        Ok(())
388    }
389
390    fn build_kernel(
391        &self,
392        version_entry: &VersionEntry,
393        replace: bool,
394        pb: &mut ProgressBar,
395    ) -> Result<(), BuilderErr> {
396        pb.set_message(BuildStep::ConfigUpdate.label());
397
398        if self
399            .boot_manager
400            .check_new_config_options(&version_entry.path)?
401        {
402            tracing::debug!("New config options detected, running olddefconfig");
403            self.boot_manager.run_olddefconfig(&version_entry.path)?;
404        }
405        pb.inc(1);
406
407        let threads = std::thread::available_parallelism()
408            .unwrap_or(NonZeroUsize::new(1).unwrap())
409            .get();
410
411        pb.set_message(BuildStep::BuildKernel.label());
412
413        let output = self
414            .boot_manager
415            .build_kernel(&version_entry.path, threads)?;
416
417        let stdout = String::from_utf8_lossy(&output.stdout);
418        tracing::debug!("Build output: {}", stdout.lines().last().unwrap_or(""));
419        pb.inc(1);
420
421        pb.set_message(BuildStep::InstallKernel.label());
422        self.boot_manager
423            .install_kernel(&version_entry.path, replace)?;
424        pb.inc(1);
425
426        Ok(())
427    }
428
429    fn install_modules(
430        &self,
431        version_entry: &VersionEntry,
432        pb: &mut ProgressBar,
433    ) -> Result<(), BuilderErr> {
434        pb.set_message(BuildStep::InstallModules.label());
435        self.boot_manager.install_modules(&version_entry.path)?;
436        pb.inc(1);
437        Ok(())
438    }
439
440    #[cfg(feature = "dracut")]
441    fn generate_initramfs(
442        &self,
443        version_entry: &VersionEntry,
444        replace: bool,
445        pb: &mut ProgressBar,
446    ) -> Result<(), BuilderErr> {
447        pb.set_message(BuildStep::GenerateInitramfs.label());
448
449        let output = self
450            .boot_manager
451            .generate_initramfs(version_entry, replace)?;
452
453        let stdout = String::from_utf8_lossy(&output.stdout);
454        tracing::debug!("Initramfs output: {}", stdout.lines().last().unwrap_or(""));
455        pb.inc(1);
456        Ok(())
457    }
458
459    fn prompt_for_kernel_version(&self) -> Option<VersionEntry> {
460        if self.versions.is_empty() {
461            tracing::debug!(
462                "No kernel versions found in {}",
463                self.config.kernel_src.display()
464            );
465            return None;
466        }
467
468        let version_strings: Vec<&str> = self
469            .versions
470            .iter()
471            .map(|v| v.version_string.as_str())
472            .collect();
473
474        Select::with_theme(&ColorfulTheme::default())
475            .with_prompt("Pick version to build and install")
476            .items(&version_strings)
477            .default(0)
478            .interact_on_opt(&Term::stderr())
479            .ok()
480            .flatten()
481            .map(|idx| self.versions[idx].clone())
482    }
483
484    fn confirm_prompt(message: &str) -> Result<bool, BuilderErr> {
485        Confirm::new()
486            .with_prompt(message)
487            .interact()
488            .map_err(BuilderErr::PromptError)
489    }
490}