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(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 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
265const SYSTEMD_LOADER_CONF_SRC: &str = "usr/lib/bootc/install/systemd-boot/loader.conf";
269const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf";
271
272#[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 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 ];
297
298 if configopts.generic_image {
299 bootctl_args.push("--no-variables");
300 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 .env("SYSTEMD_RELAX_ESP_CHECKS", "1")
316 .env("KERNEL_INSTALL_CONF_ROOT", KERNEL_INSTALL_CONF_ROOT)
320 .env("SYSTEMD_IN_CHROOT", "0")
328 .log_debug()
329 .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 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 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#[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
408pub(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 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 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 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 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 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}