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