Skip to main content

bootc_lib/
bootloader.rs

1use std::fs::create_dir_all;
2use std::process::Command;
3
4use anyhow::{Context, Result, anyhow, bail};
5use bootc_utils::{ChrootCmd, 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::{MountedImageRoot, SecurebootKeys};
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/// Redirect bootctl's entry-token write into a tmpfs scratch area.
27///
28/// bootctl unconditionally writes `<KERNEL_INSTALL_CONF_ROOT>/entry-token`
29/// during installation.  Because systemd's `path_join()` is naive string
30/// concatenation (see `src/bootctl/bootctl-install.c`), setting this to
31/// `/tmp` causes the write to land at `<composefs_root>/tmp/entry-token`
32/// on the MountedImageRoot tmpfs, where it is automatically discarded.
33/// bootc does not use the entry-token at all.
34const KERNEL_INSTALL_CONF_ROOT: &str = "/tmp";
35
36/// Mount the first ESP found among backing devices at /boot/efi.
37///
38/// This is used by the install-alongside path to clean stale bootloader
39/// files before reinstallation.  On multi-device setups only the first
40/// ESP is mounted and cleaned; stale files on additional ESPs are left
41/// in place (bootupd will overwrite them during installation).
42// TODO: clean all ESPs on multi-device setups
43pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) -> Result<()> {
44    let efi_path = Utf8Path::new("boot").join(crate::bootloader::EFI_DIR);
45    let Some(esp_fd) = root
46        .open_dir_optional(&efi_path)
47        .context("Opening /boot/efi")?
48    else {
49        return Ok(());
50    };
51
52    let Some(false) = esp_fd.is_mountpoint(".")? else {
53        return Ok(());
54    };
55
56    tracing::debug!("Not a mountpoint: /boot/efi");
57    // On ostree env with enabled composefs, should be /target/sysroot
58    let physical_root = if is_ostree {
59        &root.open_dir("sysroot").context("Opening /sysroot")?
60    } else {
61        root
62    };
63
64    let roots = bootc_blockdev::list_dev_by_dir(physical_root)?.find_all_roots()?;
65    for dev in &roots {
66        if let Some(esp_dev) = dev.find_partition_of_esp_optional()? {
67            let esp_path = esp_dev.path();
68            bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?;
69            tracing::debug!("Mounted {esp_path} at /boot/efi");
70            return Ok(());
71        }
72    }
73    tracing::debug!(
74        "No ESP partition found among {} root device(s)",
75        roots.len()
76    );
77    Ok(())
78}
79
80/// Determine if the invoking environment contains bootupd, and if there are bootupd-based
81/// updates in the target root.
82#[context("Querying for bootupd")]
83pub(crate) fn supports_bootupd(root: &Dir) -> Result<bool> {
84    if !utils::have_executable("bootupctl")? {
85        tracing::trace!("No bootupctl binary found");
86        return Ok(false);
87    };
88    let r = root.try_exists(BOOTUPD_UPDATES)?;
89    tracing::trace!("bootupd updates: {r}");
90    Ok(r)
91}
92
93/// Check whether the target bootupd supports `--filesystem`.
94///
95/// Runs `bootupctl backend install --help` and looks for `--filesystem` in the
96/// output. When `deployment_path` is set the command runs inside a chroot
97/// (via [`ChrootCmd`]) so we probe the binary from the target image.
98fn bootupd_supports_filesystem(rootfs: &Utf8Path, deployment_path: Option<&str>) -> Result<bool> {
99    let help_args = ["bootupctl", "backend", "install", "--help"];
100    let output = if let Some(deploy) = deployment_path {
101        let target_root = rootfs.join(deploy);
102        ChrootCmd::new(&target_root)
103            .set_default_path()
104            .run_get_string(help_args)?
105    } else {
106        Command::new("bootupctl")
107            .args(&help_args[1..])
108            .log_debug()
109            .run_get_string()?
110    };
111
112    let use_filesystem = output.contains("--filesystem");
113
114    if use_filesystem {
115        tracing::debug!("bootupd supports --filesystem");
116    } else {
117        tracing::debug!("bootupd does not support --filesystem, falling back to --device");
118    }
119
120    Ok(use_filesystem)
121}
122
123/// Install the bootloader via bootupd.
124///
125/// When the target bootupd supports `--filesystem` we pass it pointing at a
126/// block-backed mount so that bootupd can resolve the backing device(s) itself
127/// via `lsblk`.  In the chroot path we bind-mount the physical root at
128/// `/sysroot` to give `lsblk` a real block-backed path.
129///
130/// For older bootupd versions that lack `--filesystem` we fall back to the
131/// legacy `--device <device_path> <rootfs>` invocation.
132#[context("Installing bootloader")]
133pub(crate) fn install_via_bootupd(
134    device: &bootc_blockdev::Device,
135    rootfs: &Utf8Path,
136    configopts: &crate::install::InstallConfigOpts,
137    deployment_path: Option<&str>,
138) -> Result<()> {
139    let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv");
140    // bootc defaults to only targeting the platform boot method.
141    let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]);
142
143    // When not running inside the target container (through `--src-imgref`) we
144    // run bootupctl from the deployment via a chroot ([`ChrootCmd`]).
145    // This makes sure we use binaries from the target image rather than the buildroot.
146    // In that case, the target rootfs is replaced with `/` because this is just used by
147    // bootupd to find the backing device.
148    let rootfs_mount = if deployment_path.is_none() {
149        rootfs.as_str()
150    } else {
151        "/"
152    };
153
154    println!("Installing bootloader via bootupd");
155
156    // Build the bootupctl arguments
157    let mut bootupd_args: Vec<&str> = vec!["backend", "install"];
158    if configopts.bootupd_skip_boot_uuid {
159        bootupd_args.push("--with-static-configs")
160    } else {
161        bootupd_args.push("--write-uuid");
162    }
163    if let Some(v) = verbose {
164        bootupd_args.push(v);
165    }
166
167    if let Some(ref opts) = bootupd_opts {
168        bootupd_args.extend(opts.iter().copied());
169    }
170
171    // When the target bootupd lacks --filesystem support, fall back to the
172    // legacy --device flag.  For --device we need the whole-disk device path
173    // (e.g. /dev/vda), not a partition (e.g. /dev/vda3), so resolve the
174    // parent via require_single_root().  (Older bootupd doesn't support
175    // multiple backing devices anyway.)
176    // Computed before building bootupd_args so the String lives long enough.
177    let root_device_path = if bootupd_supports_filesystem(rootfs, deployment_path)
178        .context("Probing bootupd --filesystem support")?
179    {
180        None
181    } else {
182        Some(device.require_single_root()?.path())
183    };
184    if let Some(ref dev) = root_device_path {
185        tracing::debug!("bootupd does not support --filesystem, falling back to --device {dev}");
186        bootupd_args.extend(["--device", dev]);
187        bootupd_args.push(rootfs_mount);
188    } else {
189        tracing::debug!("bootupd supports --filesystem");
190        bootupd_args.extend(["--filesystem", rootfs_mount]);
191        bootupd_args.push(rootfs_mount);
192    }
193
194    // Run inside a chroot ([`ChrootCmd`]). It sets up a fresh mount
195    // namespace and the necessary API filesystems in the target
196    // deployment, without requiring a user namespace (which fails under
197    // qemu-user — see <https://github.com/bootc-dev/bootc/issues/2111>).
198    if let Some(deploy) = deployment_path {
199        let target_root = rootfs.join(deploy);
200        let boot_path = rootfs.join("boot");
201        let rootfs_path = rootfs.to_path_buf();
202
203        tracing::debug!("Running bootupctl via chroot in {}", target_root);
204
205        // Prepend "bootupctl" to the args (ChrootCmd's calling
206        // convention puts the program in args[0]).
207        let mut chroot_args = vec!["bootupctl"];
208        chroot_args.extend(bootupd_args);
209
210        let mut cmd = ChrootCmd::new(&target_root)
211            // Bind mount /boot from the physical target root so bootupctl can find
212            // the boot partition and install the bootloader there
213            .bind(&boot_path, &"/boot");
214
215        // Only bind mount the physical root at /sysroot when using --filesystem;
216        // bootupd needs it to resolve backing block devices via lsblk.
217        if root_device_path.is_none() {
218            cmd = cmd.bind(&rootfs_path, &"/sysroot");
219        }
220
221        // ChrootCmd starts the child with a cleared environment, so we
222        // inject a default $PATH for it to find sub-tools.
223        cmd.set_default_path().run(chroot_args)
224    } else {
225        // Running directly without chroot
226        Command::new("bootupctl")
227            .args(&bootupd_args)
228            .log_debug()
229            .run_inherited_with_cmd_context()
230    }
231}
232
233/// Install systemd-boot using a pre-prepared boot root.
234#[context("Installing bootloader")]
235pub(crate) fn install_systemd_boot(
236    prepared_root: &MountedImageRoot,
237    configopts: &crate::install::InstallConfigOpts,
238    autoenroll: Option<SecurebootKeys>,
239) -> Result<()> {
240    println!("Installing bootloader via systemd-boot");
241
242    // We use the --root of the mounted target root, so we have the right /etc/os-release.
243    let root_path = prepared_root
244        .root_path()
245        .to_str()
246        .ok_or_else(|| anyhow::anyhow!("composefs tmpdir path is not UTF-8"))?;
247    let esp_path_in_root = format!("/{}", prepared_root.esp_subdir);
248
249    let mut bootctl_args = vec![
250        "install",
251        "--root",
252        root_path,
253        "--esp-path",
254        esp_path_in_root.as_str(),
255        // If we supported XBOOTLDR in the future, that'd go here with --boot-path.
256    ];
257
258    if configopts.generic_image {
259        bootctl_args.extend(["--random-seed", "no", "--no-variables"]);
260    }
261
262    Command::new("bootctl")
263        .args(bootctl_args)
264        // Skip partition-type GUID validation because e.g. osbuild
265        // may not provide the udev database.
266        .env("SYSTEMD_RELAX_ESP_CHECKS", "1")
267        // bootc doesn't use the entry-token file, but bootctl still tries to
268        // write it.  Redirect into /tmp (a tmpfs mounted by MountedImageRoot)
269        // so the write succeeds and is automatically discarded.
270        .env("KERNEL_INSTALL_CONF_ROOT", KERNEL_INSTALL_CONF_ROOT)
271        .log_debug()
272        // Capture stderr so bootctl error messages appear in our error chain.
273        .run_capture_stderr()?;
274
275    if let Some(SecurebootKeys { dir, keys }) = autoenroll {
276        let esp_dir = prepared_root.open_esp_dir()?;
277        let keys_path = prepared_root
278            .root_path()
279            .join(prepared_root.esp_subdir)
280            .join(SYSTEMD_KEY_DIR);
281        create_dir_all(&keys_path).with_context(|| {
282            format!("Creating secureboot key directory {}", keys_path.display())
283        })?;
284
285        let keys_dir = esp_dir
286            .open_dir(SYSTEMD_KEY_DIR)
287            .with_context(|| format!("Opening {SYSTEMD_KEY_DIR}"))?;
288
289        for filename in keys.iter() {
290            // Each key lives in a subdirectory, e.g. "PK/PK.auth".
291            // Create the per-key subdirectory before copying the file into it.
292            if let Some(parent) = filename.parent() {
293                if !parent.as_str().is_empty() {
294                    keys_dir
295                        .create_dir_all(parent)
296                        .with_context(|| format!("Creating key subdirectory {parent}"))?;
297                }
298            }
299            dir.copy(filename, &keys_dir, filename)
300                .with_context(|| format!("Copying secure boot key {filename:?}"))?;
301            println!(
302                "Wrote Secure Boot key: {}/{}",
303                keys_path.display(),
304                filename.as_str()
305            );
306        }
307        if keys.is_empty() {
308            tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment");
309        }
310    }
311
312    Ok(())
313}
314
315#[context("Installing bootloader using zipl")]
316pub(crate) fn install_via_zipl(device: &bootc_blockdev::Device, boot_uuid: &str) -> Result<()> {
317    // Identify the target boot partition from UUID
318    let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
319    let boot_dir = Utf8Path::new(&fs.target);
320    let maj_min = fs.maj_min;
321
322    // Ensure that the found partition is a part of the target device
323    let device_path = device.path();
324
325    let partitions = bootc_blockdev::list_dev(Utf8Path::new(&device_path))?
326        .children
327        .with_context(|| format!("no partition found on {device_path}"))?;
328    let boot_part = partitions
329        .iter()
330        .find(|part| part.maj_min.as_deref() == Some(maj_min.as_str()))
331        .with_context(|| format!("partition device {maj_min} is not on {device_path}"))?;
332    let boot_part_offset = boot_part.start.unwrap_or(0);
333
334    // Find exactly one BLS configuration under /boot/loader/entries
335    // TODO: utilize the BLS parser in ostree
336    let bls_dir = boot_dir.join("boot/loader/entries");
337    let bls_entry = bls_dir
338        .read_dir_utf8()?
339        .try_fold(None, |acc, e| -> Result<_> {
340            let e = e?;
341            let name = Utf8Path::new(e.file_name());
342            if let Some("conf") = name.extension() {
343                if acc.is_some() {
344                    bail!("more than one BLS configurations under {bls_dir}");
345                }
346                Ok(Some(e.path().to_owned()))
347            } else {
348                Ok(None)
349            }
350        })?
351        .with_context(|| format!("no BLS configuration under {bls_dir}"))?;
352
353    let bls_path = bls_dir.join(bls_entry);
354    let bls_conf =
355        std::fs::read_to_string(&bls_path).with_context(|| format!("reading {bls_path}"))?;
356
357    let mut kernel = None;
358    let mut initrd = None;
359    let mut options = None;
360
361    for line in bls_conf.lines() {
362        match line.split_once(char::is_whitespace) {
363            Some(("linux", val)) => kernel = Some(val.trim().trim_start_matches('/')),
364            Some(("initrd", val)) => initrd = Some(val.trim().trim_start_matches('/')),
365            Some(("options", val)) => options = Some(val.trim()),
366            _ => (),
367        }
368    }
369
370    let kernel = kernel.ok_or_else(|| anyhow!("missing 'linux' key in default BLS config"))?;
371    let initrd = initrd.ok_or_else(|| anyhow!("missing 'initrd' key in default BLS config"))?;
372    let options = options.ok_or_else(|| anyhow!("missing 'options' key in default BLS config"))?;
373
374    let image = boot_dir.join(kernel).canonicalize_utf8()?;
375    let ramdisk = boot_dir.join(initrd).canonicalize_utf8()?;
376
377    // Execute the zipl command to install bootloader
378    println!("Running zipl on {device_path}");
379    Command::new("zipl")
380        .args(["--target", boot_dir.as_str()])
381        .args(["--image", image.as_str()])
382        .args(["--ramdisk", ramdisk.as_str()])
383        .args(["--parameters", options])
384        .args(["--targetbase", &device_path])
385        .args(["--targettype", "SCSI"])
386        .args(["--targetblocksize", "512"])
387        .args(["--targetoffset", &boot_part_offset.to_string()])
388        .args(["--add-files", "--verbose"])
389        .log_debug()
390        .run_inherited_with_cmd_context()
391}