bootc_lib/
bootloader.rs

1use std::fs::create_dir_all;
2use std::process::Command;
3
4use anyhow::{Context, Result, anyhow, bail};
5use bootc_utils::{BwrapCmd, CommandRunExt};
6use camino::Utf8Path;
7use cap_std_ext::cap_std::fs::Dir;
8use cap_std_ext::dirext::CapStdExtDirExt;
9use fn_error_context::context;
10
11use bootc_mount as mount;
12
13use crate::bootc_composefs::boot::{SecurebootKeys, mount_esp};
14use crate::utils;
15
16/// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel)
17pub(crate) const EFI_DIR: &str = "efi";
18/// The EFI system partition GUID
19/// Path to the bootupd update payload
20#[allow(dead_code)]
21const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates";
22
23// from: https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392
24const SYSTEMD_KEY_DIR: &str = "loader/keys";
25
26/// Mount the first ESP found among backing devices at /boot/efi.
27///
28/// This is used by the install-alongside path to clean stale bootloader
29/// files before reinstallation.  On multi-device setups only the first
30/// ESP is mounted and cleaned; stale files on additional ESPs are left
31/// in place (bootupd will overwrite them during installation).
32// TODO: clean all ESPs on multi-device setups
33pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) -> Result<()> {
34    let efi_path = Utf8Path::new("boot").join(crate::bootloader::EFI_DIR);
35    let Some(esp_fd) = root
36        .open_dir_optional(&efi_path)
37        .context("Opening /boot/efi")?
38    else {
39        return Ok(());
40    };
41
42    let Some(false) = esp_fd.is_mountpoint(".")? else {
43        return Ok(());
44    };
45
46    tracing::debug!("Not a mountpoint: /boot/efi");
47    // On ostree env with enabled composefs, should be /target/sysroot
48    let physical_root = if is_ostree {
49        &root.open_dir("sysroot").context("Opening /sysroot")?
50    } else {
51        root
52    };
53
54    let roots = bootc_blockdev::list_dev_by_dir(physical_root)?.find_all_roots()?;
55    for dev in &roots {
56        if let Some(esp_dev) = dev.find_partition_of_esp_optional()? {
57            let esp_path = esp_dev.path();
58            bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?;
59            tracing::debug!("Mounted {esp_path} at /boot/efi");
60            return Ok(());
61        }
62    }
63    tracing::debug!(
64        "No ESP partition found among {} root device(s)",
65        roots.len()
66    );
67    Ok(())
68}
69
70/// Determine if the invoking environment contains bootupd, and if there are bootupd-based
71/// updates in the target root.
72#[context("Querying for bootupd")]
73pub(crate) fn supports_bootupd(root: &Dir) -> Result<bool> {
74    if !utils::have_executable("bootupctl")? {
75        tracing::trace!("No bootupctl binary found");
76        return Ok(false);
77    };
78    let r = root.try_exists(BOOTUPD_UPDATES)?;
79    tracing::trace!("bootupd updates: {r}");
80    Ok(r)
81}
82
83/// Check whether the target bootupd supports `--filesystem`.
84///
85/// Runs `bootupctl backend install --help` and looks for `--filesystem` in the
86/// output. When `deployment_path` is set the command runs inside a bwrap
87/// container so we probe the binary from the target image.
88fn bootupd_supports_filesystem(rootfs: &Utf8Path, deployment_path: Option<&str>) -> Result<bool> {
89    let help_args = ["bootupctl", "backend", "install", "--help"];
90    let output = if let Some(deploy) = deployment_path {
91        let target_root = rootfs.join(deploy);
92        BwrapCmd::new(&target_root)
93            .set_default_path()
94            .run_get_string(help_args)?
95    } else {
96        Command::new("bootupctl")
97            .args(&help_args[1..])
98            .log_debug()
99            .run_get_string()?
100    };
101
102    let use_filesystem = output.contains("--filesystem");
103
104    if use_filesystem {
105        tracing::debug!("bootupd supports --filesystem");
106    } else {
107        tracing::debug!("bootupd does not support --filesystem, falling back to --device");
108    }
109
110    Ok(use_filesystem)
111}
112
113/// Install the bootloader via bootupd.
114///
115/// When the target bootupd supports `--filesystem` we pass it pointing at a
116/// block-backed mount so that bootupd can resolve the backing device(s) itself
117/// via `lsblk`.  In the bwrap path we bind-mount the physical root at
118/// `/sysroot` to give `lsblk` a real block-backed path.
119///
120/// For older bootupd versions that lack `--filesystem` we fall back to the
121/// legacy `--device <device_path> <rootfs>` invocation.
122#[context("Installing bootloader")]
123pub(crate) fn install_via_bootupd(
124    device: &bootc_blockdev::Device,
125    rootfs: &Utf8Path,
126    configopts: &crate::install::InstallConfigOpts,
127    deployment_path: Option<&str>,
128) -> Result<()> {
129    let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv");
130    // bootc defaults to only targeting the platform boot method.
131    let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]);
132
133    // When not running inside the target container (through `--src-imgref`) we use
134    // will bwrap as a chroot to run bootupctl from the deployment.
135    // This makes sure we use binaries from the target image rather than the buildroot.
136    // In that case, the target rootfs is replaced with `/` because this is just used by
137    // bootupd to find the backing device.
138    let rootfs_mount = if deployment_path.is_none() {
139        rootfs.as_str()
140    } else {
141        "/"
142    };
143
144    println!("Installing bootloader via bootupd");
145
146    // Build the bootupctl arguments
147    let mut bootupd_args: Vec<&str> = vec!["backend", "install"];
148    if configopts.bootupd_skip_boot_uuid {
149        bootupd_args.push("--with-static-configs")
150    } else {
151        bootupd_args.push("--write-uuid");
152    }
153    if let Some(v) = verbose {
154        bootupd_args.push(v);
155    }
156
157    if let Some(ref opts) = bootupd_opts {
158        bootupd_args.extend(opts.iter().copied());
159    }
160
161    // When the target bootupd lacks --filesystem support, fall back to the
162    // legacy --device flag.  For --device we need the whole-disk device path
163    // (e.g. /dev/vda), not a partition (e.g. /dev/vda3), so resolve the
164    // parent via require_single_root().  (Older bootupd doesn't support
165    // multiple backing devices anyway.)
166    // Computed before building bootupd_args so the String lives long enough.
167    let root_device_path = if bootupd_supports_filesystem(rootfs, deployment_path)
168        .context("Probing bootupd --filesystem support")?
169    {
170        None
171    } else {
172        Some(device.require_single_root()?.path())
173    };
174    if let Some(ref dev) = root_device_path {
175        tracing::debug!("bootupd does not support --filesystem, falling back to --device {dev}");
176        bootupd_args.extend(["--device", dev]);
177        bootupd_args.push(rootfs_mount);
178    } else {
179        tracing::debug!("bootupd supports --filesystem");
180        bootupd_args.extend(["--filesystem", rootfs_mount]);
181        bootupd_args.push(rootfs_mount);
182    }
183
184    // Run inside a bwrap container. It takes care of mounting and creating
185    // the necessary API filesystems in the target deployment and acts as
186    // a nicer `chroot`.
187    if let Some(deploy) = deployment_path {
188        let target_root = rootfs.join(deploy);
189        let boot_path = rootfs.join("boot");
190        let rootfs_path = rootfs.to_path_buf();
191
192        tracing::debug!("Running bootupctl via bwrap in {}", target_root);
193
194        // Prepend "bootupctl" to the args for bwrap
195        let mut bwrap_args = vec!["bootupctl"];
196        bwrap_args.extend(bootupd_args);
197
198        let mut cmd = BwrapCmd::new(&target_root)
199            // Bind mount /boot from the physical target root so bootupctl can find
200            // the boot partition and install the bootloader there
201            .bind(&boot_path, &"/boot");
202
203        // Only bind mount the physical root at /sysroot when using --filesystem;
204        // bootupd needs it to resolve backing block devices via lsblk.
205        if root_device_path.is_none() {
206            cmd = cmd.bind(&rootfs_path, &"/sysroot");
207        }
208
209        // The $PATH in the bwrap env is not complete enough for some images
210        // so we inject a reasonable default.
211        cmd.set_default_path().run(bwrap_args)
212    } else {
213        // Running directly without chroot
214        Command::new("bootupctl")
215            .args(&bootupd_args)
216            .log_debug()
217            .run_inherited_with_cmd_context()
218    }
219}
220
221/// Install systemd-boot to the first ESP found among backing devices.
222///
223/// On multi-device setups only the first ESP is installed to; additional
224/// ESPs on other backing devices are left untouched.
225// TODO: install to all ESPs on multi-device setups
226#[context("Installing bootloader")]
227pub(crate) fn install_systemd_boot(
228    device: &bootc_blockdev::Device,
229    _rootfs: &Utf8Path,
230    _configopts: &crate::install::InstallConfigOpts,
231    _deployment_path: Option<&str>,
232    autoenroll: Option<SecurebootKeys>,
233) -> Result<()> {
234    let roots = device.find_all_roots()?;
235    let mut esp_part = None;
236    for root in &roots {
237        if let Some(esp) = root.find_partition_of_esp_optional()? {
238            esp_part = Some(esp);
239            break;
240        }
241    }
242    let esp_part = esp_part.ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?;
243
244    let esp_mount = mount_esp(&esp_part.path()).context("Mounting ESP")?;
245    let esp_path = Utf8Path::from_path(esp_mount.dir.path())
246        .ok_or_else(|| anyhow::anyhow!("Failed to convert ESP mount path to UTF-8"))?;
247
248    println!("Installing bootloader via systemd-boot");
249    Command::new("bootctl")
250        .args(["install", "--esp-path", esp_path.as_str()])
251        .log_debug()
252        .run_inherited_with_cmd_context()?;
253
254    if let Some(SecurebootKeys { dir, keys }) = autoenroll {
255        let path = esp_path.join(SYSTEMD_KEY_DIR);
256        create_dir_all(&path)?;
257
258        let keys_dir = esp_mount
259            .fd
260            .open_dir(SYSTEMD_KEY_DIR)
261            .with_context(|| format!("Opening {path}"))?;
262
263        for filename in keys.iter() {
264            let p = path.join(&filename);
265
266            // create directory if it doesn't already exist
267            if let Some(parent) = p.parent() {
268                create_dir_all(parent)?;
269            }
270
271            dir.copy(&filename, &keys_dir, &filename)
272                .with_context(|| format!("Copying secure boot key: {p}"))?;
273            println!("Wrote Secure Boot key: {p}");
274        }
275        if keys.is_empty() {
276            tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment");
277        }
278    }
279
280    Ok(())
281}
282
283#[context("Installing bootloader using zipl")]
284pub(crate) fn install_via_zipl(device: &bootc_blockdev::Device, boot_uuid: &str) -> Result<()> {
285    // Identify the target boot partition from UUID
286    let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
287    let boot_dir = Utf8Path::new(&fs.target);
288    let maj_min = fs.maj_min;
289
290    // Ensure that the found partition is a part of the target device
291    let device_path = device.path();
292
293    let partitions = bootc_blockdev::list_dev(Utf8Path::new(&device_path))?
294        .children
295        .with_context(|| format!("no partition found on {device_path}"))?;
296    let boot_part = partitions
297        .iter()
298        .find(|part| part.maj_min.as_deref() == Some(maj_min.as_str()))
299        .with_context(|| format!("partition device {maj_min} is not on {device_path}"))?;
300    let boot_part_offset = boot_part.start.unwrap_or(0);
301
302    // Find exactly one BLS configuration under /boot/loader/entries
303    // TODO: utilize the BLS parser in ostree
304    let bls_dir = boot_dir.join("boot/loader/entries");
305    let bls_entry = bls_dir
306        .read_dir_utf8()?
307        .try_fold(None, |acc, e| -> Result<_> {
308            let e = e?;
309            let name = Utf8Path::new(e.file_name());
310            if let Some("conf") = name.extension() {
311                if acc.is_some() {
312                    bail!("more than one BLS configurations under {bls_dir}");
313                }
314                Ok(Some(e.path().to_owned()))
315            } else {
316                Ok(None)
317            }
318        })?
319        .with_context(|| format!("no BLS configuration under {bls_dir}"))?;
320
321    let bls_path = bls_dir.join(bls_entry);
322    let bls_conf =
323        std::fs::read_to_string(&bls_path).with_context(|| format!("reading {bls_path}"))?;
324
325    let mut kernel = None;
326    let mut initrd = None;
327    let mut options = None;
328
329    for line in bls_conf.lines() {
330        match line.split_once(char::is_whitespace) {
331            Some(("linux", val)) => kernel = Some(val.trim().trim_start_matches('/')),
332            Some(("initrd", val)) => initrd = Some(val.trim().trim_start_matches('/')),
333            Some(("options", val)) => options = Some(val.trim()),
334            _ => (),
335        }
336    }
337
338    let kernel = kernel.ok_or_else(|| anyhow!("missing 'linux' key in default BLS config"))?;
339    let initrd = initrd.ok_or_else(|| anyhow!("missing 'initrd' key in default BLS config"))?;
340    let options = options.ok_or_else(|| anyhow!("missing 'options' key in default BLS config"))?;
341
342    let image = boot_dir.join(kernel).canonicalize_utf8()?;
343    let ramdisk = boot_dir.join(initrd).canonicalize_utf8()?;
344
345    // Execute the zipl command to install bootloader
346    println!("Running zipl on {device_path}");
347    Command::new("zipl")
348        .args(["--target", boot_dir.as_str()])
349        .args(["--image", image.as_str()])
350        .args(["--ramdisk", ramdisk.as_str()])
351        .args(["--parameters", options])
352        .args(["--targetbase", &device_path])
353        .args(["--targettype", "SCSI"])
354        .args(["--targetblocksize", "512"])
355        .args(["--targetoffset", &boot_part_offset.to_string()])
356        .args(["--add-files", "--verbose"])
357        .log_debug()
358        .run_inherited_with_cmd_context()
359}