Skip to main content

bootc_lib/
bootloader.rs

1use std::fs::create_dir_all;
2use std::process::Command;
3use std::sync::OnceLock;
4
5use anyhow::{Context, Result, anyhow, bail};
6use bootc_utils::{BindMode, ChrootCmd, CommandRunExt};
7use camino::Utf8Path;
8use cap_std_ext::cap_std::fs::Dir;
9use cap_std_ext::dirext::CapStdExtDirExt;
10use fn_error_context::context;
11
12use bootc_mount as mount;
13
14use crate::bootc_composefs::boot::{MountedImageRoot, SecurebootKeys};
15use crate::utils;
16
17/// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel)
18pub(crate) const EFI_DIR: &str = "efi";
19/// The EFI system partition GUID
20/// Path to the bootupd update payload
21#[allow(dead_code)]
22const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates";
23
24// from: https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392
25const SYSTEMD_KEY_DIR: &str = "loader/keys";
26
27/// Redirect bootctl's entry-token write into a tmpfs scratch area.
28///
29/// bootctl unconditionally writes `<KERNEL_INSTALL_CONF_ROOT>/entry-token`
30/// during installation.  Because systemd's `path_join()` is naive string
31/// concatenation (see `src/bootctl/bootctl-install.c`), setting this to
32/// `/tmp` causes the write to land at `<composefs_root>/tmp/entry-token`
33/// on the MountedImageRoot tmpfs, where it is automatically discarded.
34/// bootc does not use the entry-token at all.
35const KERNEL_INSTALL_CONF_ROOT: &str = "/tmp";
36
37/// First systemd release whose `bootctl install` accepts `--random-seed`.
38/// See: <https://www.freedesktop.org/software/systemd/man/latest/bootctl.html>
39const BOOTCTL_RANDOM_SEED_MIN_VERSION: u32 = 257;
40
41/// Mount the first ESP found among backing devices at /boot/efi.
42///
43/// This is used by the install-alongside path to clean stale bootloader
44/// files before reinstallation.  On multi-device setups only the first
45/// ESP is mounted and cleaned; stale files on additional ESPs are left
46/// in place (bootupd will overwrite them during installation).
47// TODO: clean all ESPs on multi-device setups
48pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) -> Result<()> {
49    let efi_path = Utf8Path::new(crate::install::BOOT).join(crate::bootloader::EFI_DIR);
50    let Some(esp_fd) = root
51        .open_dir_optional(&efi_path)
52        .context("Opening /boot/efi")?
53    else {
54        return Ok(());
55    };
56
57    let Some(false) = esp_fd.is_mountpoint(".")? else {
58        return Ok(());
59    };
60
61    tracing::debug!("Not a mountpoint: /boot/efi");
62    // On ostree env with enabled composefs, should be /target/sysroot
63    let physical_root = if is_ostree {
64        &root.open_dir("sysroot").context("Opening /sysroot")?
65    } else {
66        root
67    };
68
69    let roots = bootc_blockdev::list_dev_by_dir(physical_root)?.find_all_roots()?;
70    for dev in &roots {
71        if let Some(esp_dev) = dev.find_partition_of_esp_optional()? {
72            let esp_path = esp_dev.path();
73            bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?;
74            tracing::debug!("Mounted {esp_path} at /boot/efi");
75            return Ok(());
76        }
77    }
78    tracing::debug!(
79        "No ESP partition found among {} root device(s)",
80        roots.len()
81    );
82    Ok(())
83}
84
85/// Determine if the invoking environment contains bootupd, and if there are bootupd-based
86/// updates in the target root.
87#[context("Querying for bootupd")]
88pub(crate) fn supports_bootupd(root: &Dir) -> Result<bool> {
89    if !utils::have_executable("bootupctl")? {
90        tracing::trace!("No bootupctl binary found");
91        return Ok(false);
92    };
93    let r = root.try_exists(BOOTUPD_UPDATES)?;
94    tracing::trace!("bootupd updates: {r}");
95    Ok(r)
96}
97
98/// Check whether the target bootupd supports `--filesystem`.
99///
100/// Runs `bootupctl backend install --help` and looks for `--filesystem` in the
101/// output. When `chroot_target` is set the command runs inside a chroot
102/// (via [`ChrootCmd`]) so we probe the binary from the target image.
103fn bootupd_supports_filesystem(chroot_target: Option<&Utf8Path>) -> Result<bool> {
104    let help_args = ["bootupctl", "backend", "install", "--help"];
105    let output = if let Some(target_root) = chroot_target {
106        ChrootCmd::new(target_root)
107            .set_default_path()
108            .run_get_string(help_args)?
109    } else {
110        Command::new("bootupctl")
111            .args(&help_args[1..])
112            .log_debug()
113            .run_get_string()?
114    };
115
116    let use_filesystem = output.contains("--filesystem");
117
118    if use_filesystem {
119        tracing::debug!("bootupd supports --filesystem");
120    } else {
121        tracing::debug!("bootupd does not support --filesystem, falling back to --device");
122    }
123
124    Ok(use_filesystem)
125}
126
127/// Install the bootloader via bootupd.
128///
129/// When the target bootupd supports `--filesystem` we pass it pointing at a
130/// block-backed mount so that bootupd can resolve the backing device(s) itself
131/// via `lsblk`.  When `chroot_target` is set, bootupctl is executed inside the
132/// given chroot root via [`ChrootCmd`], with the physical root bind-mounted at
133/// `/sysroot` so `lsblk` can resolve a real block-backed path.
134///
135/// For older bootupd versions that lack `--filesystem` we fall back to the
136/// legacy `--device <device_path> <rootfs>` invocation.
137///
138/// If `bind_boot_path` is set, the given host path is bind-mounted onto
139/// `/boot` inside the chroot.  Both the ostree and composefs backends use
140/// this to expose the physical root's real `/boot` inside their respective
141/// chroots, since neither chroot target (an ostree deployment, or a mounted
142/// composefs image) has a `/boot` backed by the real root filesystem on its
143/// own.  This matters because bootupd derives the UUID it writes for
144/// `--write-uuid` from whatever filesystem is mounted at `<chroot>/boot`,
145/// and looks for an empty `boot/efi` directory there to discover and mount
146/// the real ESP into.
147#[context("Installing bootloader")]
148pub(crate) fn install_via_bootupd(
149    device: &bootc_blockdev::Device,
150    rootfs: &Utf8Path,
151    configopts: &crate::install::InstallConfigOpts,
152    chroot_target: Option<&Utf8Path>,
153    bind_boot_path: Option<&Utf8Path>,
154) -> Result<()> {
155    let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv");
156    // bootc defaults to only targeting the platform boot method.
157    let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]);
158
159    // When not running inside the target container (through `--src-imgref`) we
160    // run bootupctl from the deployment via a chroot ([`ChrootCmd`]).
161    // This makes sure we use binaries from the target image rather than the buildroot.
162    // In that case, the target rootfs is replaced with `/` because this is just used by
163    // bootupd to find the backing device.
164    let rootfs_mount = if chroot_target.is_none() {
165        rootfs.as_str()
166    } else {
167        "/"
168    };
169
170    println!("Installing bootloader via bootupd");
171
172    // Build the bootupctl arguments
173    let mut bootupd_args: Vec<&str> = vec!["backend", "install"];
174    if configopts.bootupd_skip_boot_uuid {
175        bootupd_args.push("--with-static-configs")
176    } else {
177        bootupd_args.push("--write-uuid");
178    }
179    if let Some(v) = verbose {
180        bootupd_args.push(v);
181    }
182
183    if let Some(ref opts) = bootupd_opts {
184        bootupd_args.extend(opts.iter().copied());
185    }
186
187    // When the target bootupd lacks --filesystem support, fall back to the
188    // legacy --device flag.  For --device we need the whole-disk device path
189    // (e.g. /dev/vda), not a partition (e.g. /dev/vda3), so resolve the
190    // parent via require_single_root().  (Older bootupd doesn't support
191    // multiple backing devices anyway.)
192    // Computed before building bootupd_args so the String lives long enough.
193    let root_device_path = if bootupd_supports_filesystem(chroot_target)
194        .context("Probing bootupd --filesystem support")?
195    {
196        None
197    } else {
198        Some(device.require_single_root()?.path())
199    };
200    if let Some(ref dev) = root_device_path {
201        tracing::debug!("bootupd does not support --filesystem, falling back to --device {dev}");
202        bootupd_args.extend(["--device", dev]);
203        bootupd_args.push(rootfs_mount);
204    } else {
205        tracing::debug!("bootupd supports --filesystem");
206        // Inside a chroot the physical root is bind-mounted at /sysroot (see
207        // below) so bootupd's own device resolution (via lsblk) sees a real
208        // block-backed path. This matters for composefs, where the chroot's
209        // own "/" is a virtual composefs mount with no backing block device.
210        let filesystem_path = if chroot_target.is_some() {
211            "/sysroot"
212        } else {
213            rootfs_mount
214        };
215        bootupd_args.extend(["--filesystem", filesystem_path]);
216        bootupd_args.push(rootfs_mount);
217    }
218
219    // Run inside a chroot ([`ChrootCmd`]). It sets up a fresh mount
220    // namespace and the necessary API filesystems in the target
221    // deployment, without requiring a user namespace (which fails under
222    // qemu-user — see <https://github.com/bootc-dev/bootc/issues/2111>).
223    if let Some(target_root) = chroot_target {
224        let rootfs_path = rootfs.to_path_buf();
225
226        tracing::debug!("Running bootupctl via chroot in {}", target_root);
227
228        // Prepend "bootupctl" to the args (ChrootCmd's calling
229        // convention puts the program in args[0]).
230        let mut chroot_args = vec!["bootupctl"];
231        chroot_args.extend(bootupd_args);
232
233        let mut cmd = ChrootCmd::new(target_root);
234        // Bind mount /boot from the physical target root so bootupctl can find
235        // the boot partition and install the bootloader there. This is a
236        // non-recursive bind: the physical root's /boot may itself have the
237        // ESP mounted at boot/efi (see clean_boot_directories()), and we
238        // don't want that mount to be dragged along, since bootupd's own EFI
239        // component expects to find an empty boot/efi directory to mount the
240        // real ESP onto itself (see MountedImageRoot::with_esp()). A stray
241        // nested ESP mount there has also been observed to confuse grub-probe
242        // into embedding the wrong root device in the BIOS boot prefix.
243        if let Some(boot_path) = &bind_boot_path {
244            cmd = cmd.bind(boot_path, &"/boot", BindMode::Default);
245        }
246
247        // Only bind mount the physical root at /sysroot when using --filesystem;
248        // bootupd needs it to resolve backing block devices via lsblk.
249        if root_device_path.is_none() {
250            cmd = cmd.bind(&rootfs_path, &"/sysroot", BindMode::Recursive);
251        }
252
253        // ChrootCmd starts the child with a cleared environment, so we
254        // inject a default $PATH for it to find sub-tools.
255        cmd.set_default_path().run(chroot_args)
256    } else {
257        // Running directly without chroot
258        Command::new("bootupctl")
259            .args(&bootupd_args)
260            .log_debug()
261            .run_inherited_with_cmd_context()
262    }
263}
264
265/// Path in the container image where an image author may ship an initial
266/// systemd-boot configuration. When present, it is copied verbatim onto the
267/// ESP after `bootctl install`
268const SYSTEMD_LOADER_CONF_SRC: &str = "usr/lib/bootc/install/systemd-boot/loader.conf";
269/// Destination of the systemd-boot loader configuration on the ESP
270const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf";
271
272/// Install systemd-boot using a pre-prepared boot root.
273#[context("Installing bootloader")]
274pub(crate) fn install_systemd_boot(
275    prepared_root: &MountedImageRoot,
276    configopts: &crate::install::InstallConfigOpts,
277    autoenroll: Option<SecurebootKeys>,
278) -> Result<()> {
279    println!("Installing bootloader via systemd-boot");
280
281    // We use the --root of the mounted target root, so we have the right /etc/os-release.
282    let root_path = prepared_root
283        .root_path()
284        .to_str()
285        .ok_or_else(|| anyhow::anyhow!("composefs tmpdir path is not UTF-8"))?;
286    let esp_path_in_root = format!("/{}", prepared_root.esp_subdir);
287
288    let mut bootctl_args = vec![
289        "install",
290        "--root",
291        root_path,
292        "--esp-path",
293        esp_path_in_root.as_str(),
294        // No --boot-path: bootc writes the boot entries itself, to an
295        // XBOOTLDR partition if there is one (see `BootPartition`).
296    ];
297
298    if configopts.generic_image {
299        bootctl_args.push("--no-variables");
300        // `--random-seed` was only added to `bootctl install` in systemd 257.
301        let systemd_version = systemd_version()?;
302        if systemd_version >= BOOTCTL_RANDOM_SEED_MIN_VERSION {
303            bootctl_args.extend(["--random-seed", "no"]);
304        } else {
305            tracing::debug!(
306                "Skipping --random-seed: requires systemd >= {BOOTCTL_RANDOM_SEED_MIN_VERSION}, found {systemd_version}"
307            );
308        }
309    }
310
311    Command::new("bootctl")
312        .args(bootctl_args)
313        // Skip partition-type GUID validation because e.g. osbuild
314        // may not provide the udev database.
315        .env("SYSTEMD_RELAX_ESP_CHECKS", "1")
316        // bootc doesn't use the entry-token file, but bootctl still tries to
317        // write it.  Redirect into /tmp (a tmpfs mounted by MountedImageRoot)
318        // so the write succeeds and is automatically discarded.
319        .env("KERNEL_INSTALL_CONF_ROOT", KERNEL_INSTALL_CONF_ROOT)
320        // If systemd thinks it's inside of a chroot, it will fall
321        // back to "graceful" mode.  This means that bootctl will exit
322        // 0 even if bootloader installation doesn't go as expected,
323        // and we don't want that.  Explicitly disable chroot
324        // detection.
325        //
326        // See: https://github.com/bootc-dev/bootc/issues/2486
327        .env("SYSTEMD_IN_CHROOT", "0")
328        .log_debug()
329        // Capture stderr so bootctl error messages appear in our error chain.
330        .run_capture_stderr()?;
331
332    if let Some(SecurebootKeys { dir, keys }) = autoenroll {
333        let esp_dir = prepared_root.open_esp_dir()?;
334        let keys_path = prepared_root
335            .root_path()
336            .join(prepared_root.esp_subdir)
337            .join(SYSTEMD_KEY_DIR);
338        create_dir_all(&keys_path).with_context(|| {
339            format!("Creating secureboot key directory {}", keys_path.display())
340        })?;
341
342        let keys_dir = esp_dir
343            .open_dir(SYSTEMD_KEY_DIR)
344            .with_context(|| format!("Opening {SYSTEMD_KEY_DIR}"))?;
345
346        for filename in keys.iter() {
347            // Each key lives in a subdirectory, e.g. "PK/PK.auth".
348            // Create the per-key subdirectory before copying the file into it.
349            if let Some(parent) = filename.parent() {
350                if !parent.as_str().is_empty() {
351                    keys_dir
352                        .create_dir_all(parent)
353                        .with_context(|| format!("Creating key subdirectory {parent}"))?;
354                }
355            }
356            dir.copy(filename, &keys_dir, filename)
357                .with_context(|| format!("Copying secure boot key {filename:?}"))?;
358            println!(
359                "Wrote Secure Boot key: {}/{}",
360                keys_path.display(),
361                filename.as_str()
362            );
363        }
364        if keys.is_empty() {
365            tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment");
366        }
367    }
368
369    // If the container image ships an initial systemd-boot configuration,
370    // copy it verbatim onto the ESP, overwriting the one generated by bootctl
371    if let Some(mut src) = prepared_root
372        .dir()
373        .open_optional(SYSTEMD_LOADER_CONF_SRC)
374        .context("Opening image systemd-boot loader config")?
375    {
376        let esp_dir = prepared_root.open_esp_dir()?;
377
378        esp_dir
379            .atomic_replace_with(SYSTEMD_LOADER_CONF_PATH, |writer| {
380                std::io::copy(&mut src, writer)
381            })
382            .with_context(|| format!("Writing to {SYSTEMD_LOADER_CONF_PATH}"))?;
383    }
384
385    Ok(())
386}
387
388/// Query the major version of systemd via `systemctl --version`, caching the
389/// result so it can be shared across callers (bootctl, systemd-repart, etc.).
390#[context("Querying systemd version")]
391pub(crate) fn systemd_version() -> Result<u32> {
392    static VERSION: OnceLock<u32> = OnceLock::new();
393
394    if let Some(v) = VERSION.get() {
395        return Ok(*v);
396    };
397
398    let out = Command::new("systemctl")
399        .arg("--version")
400        .run_get_string()?;
401    let v = parse_systemd_version(&out).context("Failed to parse version to integer")?;
402
403    let version = VERSION.get_or_init(|| v);
404
405    Ok(*version)
406}
407
408/// Parse the systemd major version from `bootctl --version` output, whose first
409/// line looks like `systemd 259 (259.5-0ubuntu3)`.
410pub(crate) fn parse_systemd_version(output: &str) -> Result<u32> {
411    output
412        .split_whitespace()
413        .nth(1)
414        .and_then(|s| s.parse::<u32>().ok())
415        .ok_or_else(|| {
416            anyhow!("Could not parse systemd version from bootctl --version: {output:?}")
417        })
418}
419
420#[context("Installing bootloader using zipl")]
421pub(crate) fn install_via_zipl(device: &bootc_blockdev::Device, boot_uuid: &str) -> Result<()> {
422    // Identify the target boot partition from UUID
423    let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
424    let boot_dir = Utf8Path::new(&fs.target);
425    let maj_min = fs.maj_min;
426
427    // Ensure that the found partition is a part of the target device
428    let device_path = device.path();
429
430    let partitions = bootc_blockdev::list_dev(Utf8Path::new(&device_path))?
431        .children
432        .with_context(|| format!("no partition found on {device_path}"))?;
433    let boot_part = partitions
434        .iter()
435        .find(|part| part.maj_min.as_deref() == Some(maj_min.as_str()))
436        .with_context(|| format!("partition device {maj_min} is not on {device_path}"))?;
437    let boot_part_offset = boot_part.start.unwrap_or(0);
438
439    // Find exactly one BLS configuration under /boot/loader/entries
440    // TODO: utilize the BLS parser in ostree
441    let bls_dir = boot_dir.join("boot/loader/entries");
442    let bls_entry = bls_dir
443        .read_dir_utf8()?
444        .try_fold(None, |acc, e| -> Result<_> {
445            let e = e?;
446            let name = Utf8Path::new(e.file_name());
447            if let Some("conf") = name.extension() {
448                if acc.is_some() {
449                    bail!("more than one BLS configurations under {bls_dir}");
450                }
451                Ok(Some(e.path().to_owned()))
452            } else {
453                Ok(None)
454            }
455        })?
456        .with_context(|| format!("no BLS configuration under {bls_dir}"))?;
457
458    let bls_path = bls_dir.join(bls_entry);
459    let bls_conf =
460        std::fs::read_to_string(&bls_path).with_context(|| format!("reading {bls_path}"))?;
461
462    let mut kernel = None;
463    let mut initrd = None;
464    let mut options = None;
465
466    for line in bls_conf.lines() {
467        match line.split_once(char::is_whitespace) {
468            Some(("linux", val)) => kernel = Some(val.trim().trim_start_matches('/')),
469            Some(("initrd", val)) => initrd = Some(val.trim().trim_start_matches('/')),
470            Some(("options", val)) => options = Some(val.trim()),
471            _ => (),
472        }
473    }
474
475    let kernel = kernel.ok_or_else(|| anyhow!("missing 'linux' key in default BLS config"))?;
476    let initrd = initrd.ok_or_else(|| anyhow!("missing 'initrd' key in default BLS config"))?;
477    let options = options.ok_or_else(|| anyhow!("missing 'options' key in default BLS config"))?;
478
479    let image = boot_dir.join(kernel).canonicalize_utf8()?;
480    let ramdisk = boot_dir.join(initrd).canonicalize_utf8()?;
481
482    // Execute the zipl command to install bootloader
483    println!("Running zipl on {device_path}");
484    Command::new("zipl")
485        .args(["--target", boot_dir.as_str()])
486        .args(["--image", image.as_str()])
487        .args(["--ramdisk", ramdisk.as_str()])
488        .args(["--parameters", options])
489        .args(["--targetbase", &device_path])
490        .args(["--targettype", "SCSI"])
491        .args(["--targetblocksize", "512"])
492        .args(["--targetoffset", &boot_part_offset.to_string()])
493        .args(["--add-files", "--verbose"])
494        .log_debug()
495        .run_inherited_with_cmd_context()
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_parse_systemd_version() {
504        // The first line of `bootctl --version`. the trailing feature line is ignored.
505        let cases = [
506            ("systemd 259 (259.5-0ubuntu3)", 259),
507            ("systemd 257 (257-26.el10-g1d19ad5)", 257),
508            ("systemd 255 (255.4-1ubuntu8.16)", 255),
509        ];
510        for (input, expected) in cases {
511            assert_eq!(
512                parse_systemd_version(input).unwrap(),
513                expected,
514                "input: {input:?}"
515            );
516        }
517        for bad in ["", "systemd", "not a version string"] {
518            assert!(
519                parse_systemd_version(bad).is_err(),
520                "should reject: {bad:?}"
521            );
522        }
523    }
524}