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
17pub(crate) const EFI_DIR: &str = "efi";
19#[allow(dead_code)]
22const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates";
23
24const SYSTEMD_KEY_DIR: &str = "loader/keys";
26
27const KERNEL_INSTALL_CONF_ROOT: &str = "/tmp";
36
37const BOOTCTL_RANDOM_SEED_MIN_VERSION: u32 = 257;
40
41pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) -> Result<()> {
49 let efi_path = Utf8Path::new("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 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#[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
98fn 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#[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 let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]);
158
159 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 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 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 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 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 let mut chroot_args = vec!["bootupctl"];
231 chroot_args.extend(bootupd_args);
232
233 let mut cmd = ChrootCmd::new(target_root);
234 if let Some(boot_path) = &bind_boot_path {
244 cmd = cmd.bind(boot_path, &"/boot", BindMode::Default);
245 }
246
247 if root_device_path.is_none() {
250 cmd = cmd.bind(&rootfs_path, &"/sysroot", BindMode::Recursive);
251 }
252
253 cmd.set_default_path().run(chroot_args)
256 } else {
257 Command::new("bootupctl")
259 .args(&bootupd_args)
260 .log_debug()
261 .run_inherited_with_cmd_context()
262 }
263}
264
265#[context("Installing bootloader")]
267pub(crate) fn install_systemd_boot(
268 prepared_root: &MountedImageRoot,
269 configopts: &crate::install::InstallConfigOpts,
270 autoenroll: Option<SecurebootKeys>,
271) -> Result<()> {
272 println!("Installing bootloader via systemd-boot");
273
274 let root_path = prepared_root
276 .root_path()
277 .to_str()
278 .ok_or_else(|| anyhow::anyhow!("composefs tmpdir path is not UTF-8"))?;
279 let esp_path_in_root = format!("/{}", prepared_root.esp_subdir);
280
281 let mut bootctl_args = vec![
282 "install",
283 "--root",
284 root_path,
285 "--esp-path",
286 esp_path_in_root.as_str(),
287 ];
289
290 if configopts.generic_image {
291 bootctl_args.push("--no-variables");
292 let systemd_version = bootctl_systemd_version()?;
294 if systemd_version >= BOOTCTL_RANDOM_SEED_MIN_VERSION {
295 bootctl_args.extend(["--random-seed", "no"]);
296 } else {
297 tracing::debug!(
298 "Skipping --random-seed: requires systemd >= {BOOTCTL_RANDOM_SEED_MIN_VERSION}, found {systemd_version}"
299 );
300 }
301 }
302
303 Command::new("bootctl")
304 .args(bootctl_args)
305 .env("SYSTEMD_RELAX_ESP_CHECKS", "1")
308 .env("KERNEL_INSTALL_CONF_ROOT", KERNEL_INSTALL_CONF_ROOT)
312 .log_debug()
313 .run_capture_stderr()?;
315
316 if let Some(SecurebootKeys { dir, keys }) = autoenroll {
317 let esp_dir = prepared_root.open_esp_dir()?;
318 let keys_path = prepared_root
319 .root_path()
320 .join(prepared_root.esp_subdir)
321 .join(SYSTEMD_KEY_DIR);
322 create_dir_all(&keys_path).with_context(|| {
323 format!("Creating secureboot key directory {}", keys_path.display())
324 })?;
325
326 let keys_dir = esp_dir
327 .open_dir(SYSTEMD_KEY_DIR)
328 .with_context(|| format!("Opening {SYSTEMD_KEY_DIR}"))?;
329
330 for filename in keys.iter() {
331 if let Some(parent) = filename.parent() {
334 if !parent.as_str().is_empty() {
335 keys_dir
336 .create_dir_all(parent)
337 .with_context(|| format!("Creating key subdirectory {parent}"))?;
338 }
339 }
340 dir.copy(filename, &keys_dir, filename)
341 .with_context(|| format!("Copying secure boot key {filename:?}"))?;
342 println!(
343 "Wrote Secure Boot key: {}/{}",
344 keys_path.display(),
345 filename.as_str()
346 );
347 }
348 if keys.is_empty() {
349 tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment");
350 }
351 }
352
353 Ok(())
354}
355
356#[context("Querying bootctl version")]
357pub(crate) fn bootctl_systemd_version() -> Result<u32> {
358 static VERSION: OnceLock<u32> = OnceLock::new();
359
360 if let Some(v) = VERSION.get() {
361 return Ok(*v);
362 };
363
364 let out = Command::new("bootctl").arg("--version").run_get_string()?;
365 let v = parse_systemd_version(&out).context("Failed to parse version to integer")?;
366
367 let version = VERSION.get_or_init(|| v);
368
369 Ok(*version)
370}
371
372fn parse_systemd_version(output: &str) -> Result<u32> {
375 output
376 .split_whitespace()
377 .nth(1)
378 .and_then(|s| s.parse::<u32>().ok())
379 .ok_or_else(|| {
380 anyhow!("Could not parse systemd version from bootctl --version: {output:?}")
381 })
382}
383
384#[context("Installing bootloader using zipl")]
385pub(crate) fn install_via_zipl(device: &bootc_blockdev::Device, boot_uuid: &str) -> Result<()> {
386 let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
388 let boot_dir = Utf8Path::new(&fs.target);
389 let maj_min = fs.maj_min;
390
391 let device_path = device.path();
393
394 let partitions = bootc_blockdev::list_dev(Utf8Path::new(&device_path))?
395 .children
396 .with_context(|| format!("no partition found on {device_path}"))?;
397 let boot_part = partitions
398 .iter()
399 .find(|part| part.maj_min.as_deref() == Some(maj_min.as_str()))
400 .with_context(|| format!("partition device {maj_min} is not on {device_path}"))?;
401 let boot_part_offset = boot_part.start.unwrap_or(0);
402
403 let bls_dir = boot_dir.join("boot/loader/entries");
406 let bls_entry = bls_dir
407 .read_dir_utf8()?
408 .try_fold(None, |acc, e| -> Result<_> {
409 let e = e?;
410 let name = Utf8Path::new(e.file_name());
411 if let Some("conf") = name.extension() {
412 if acc.is_some() {
413 bail!("more than one BLS configurations under {bls_dir}");
414 }
415 Ok(Some(e.path().to_owned()))
416 } else {
417 Ok(None)
418 }
419 })?
420 .with_context(|| format!("no BLS configuration under {bls_dir}"))?;
421
422 let bls_path = bls_dir.join(bls_entry);
423 let bls_conf =
424 std::fs::read_to_string(&bls_path).with_context(|| format!("reading {bls_path}"))?;
425
426 let mut kernel = None;
427 let mut initrd = None;
428 let mut options = None;
429
430 for line in bls_conf.lines() {
431 match line.split_once(char::is_whitespace) {
432 Some(("linux", val)) => kernel = Some(val.trim().trim_start_matches('/')),
433 Some(("initrd", val)) => initrd = Some(val.trim().trim_start_matches('/')),
434 Some(("options", val)) => options = Some(val.trim()),
435 _ => (),
436 }
437 }
438
439 let kernel = kernel.ok_or_else(|| anyhow!("missing 'linux' key in default BLS config"))?;
440 let initrd = initrd.ok_or_else(|| anyhow!("missing 'initrd' key in default BLS config"))?;
441 let options = options.ok_or_else(|| anyhow!("missing 'options' key in default BLS config"))?;
442
443 let image = boot_dir.join(kernel).canonicalize_utf8()?;
444 let ramdisk = boot_dir.join(initrd).canonicalize_utf8()?;
445
446 println!("Running zipl on {device_path}");
448 Command::new("zipl")
449 .args(["--target", boot_dir.as_str()])
450 .args(["--image", image.as_str()])
451 .args(["--ramdisk", ramdisk.as_str()])
452 .args(["--parameters", options])
453 .args(["--targetbase", &device_path])
454 .args(["--targettype", "SCSI"])
455 .args(["--targetblocksize", "512"])
456 .args(["--targetoffset", &boot_part_offset.to_string()])
457 .args(["--add-files", "--verbose"])
458 .log_debug()
459 .run_inherited_with_cmd_context()
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn test_parse_systemd_version() {
468 let cases = [
470 ("systemd 259 (259.5-0ubuntu3)", 259),
471 ("systemd 257 (257-26.el10-g1d19ad5)", 257),
472 ("systemd 255 (255.4-1ubuntu8.16)", 255),
473 ];
474 for (input, expected) in cases {
475 assert_eq!(
476 parse_systemd_version(input).unwrap(),
477 expected,
478 "input: {input:?}"
479 );
480 }
481 for bad in ["", "systemd", "not a version string"] {
482 assert!(
483 parse_systemd_version(bad).is_err(),
484 "should reject: {bad:?}"
485 );
486 }
487 }
488}