1use std::fs::create_dir_all;
2use std::process::Command;
3use std::sync::OnceLock;
4
5use anyhow::{Context, Result, anyhow, bail};
6use bootc_utils::{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(rootfs: &Utf8Path, deployment_path: Option<&str>) -> Result<bool> {
104 let help_args = ["bootupctl", "backend", "install", "--help"];
105 let output = if let Some(deploy) = deployment_path {
106 let target_root = rootfs.join(deploy);
107 ChrootCmd::new(&target_root)
108 .set_default_path()
109 .run_get_string(help_args)?
110 } else {
111 Command::new("bootupctl")
112 .args(&help_args[1..])
113 .log_debug()
114 .run_get_string()?
115 };
116
117 let use_filesystem = output.contains("--filesystem");
118
119 if use_filesystem {
120 tracing::debug!("bootupd supports --filesystem");
121 } else {
122 tracing::debug!("bootupd does not support --filesystem, falling back to --device");
123 }
124
125 Ok(use_filesystem)
126}
127
128#[context("Installing bootloader")]
138pub(crate) fn install_via_bootupd(
139 device: &bootc_blockdev::Device,
140 rootfs: &Utf8Path,
141 configopts: &crate::install::InstallConfigOpts,
142 deployment_path: Option<&str>,
143) -> Result<()> {
144 let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv");
145 let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]);
147
148 let rootfs_mount = if deployment_path.is_none() {
154 rootfs.as_str()
155 } else {
156 "/"
157 };
158
159 println!("Installing bootloader via bootupd");
160
161 let mut bootupd_args: Vec<&str> = vec!["backend", "install"];
163 if configopts.bootupd_skip_boot_uuid {
164 bootupd_args.push("--with-static-configs")
165 } else {
166 bootupd_args.push("--write-uuid");
167 }
168 if let Some(v) = verbose {
169 bootupd_args.push(v);
170 }
171
172 if let Some(ref opts) = bootupd_opts {
173 bootupd_args.extend(opts.iter().copied());
174 }
175
176 let root_device_path = if bootupd_supports_filesystem(rootfs, deployment_path)
183 .context("Probing bootupd --filesystem support")?
184 {
185 None
186 } else {
187 Some(device.require_single_root()?.path())
188 };
189 if let Some(ref dev) = root_device_path {
190 tracing::debug!("bootupd does not support --filesystem, falling back to --device {dev}");
191 bootupd_args.extend(["--device", dev]);
192 bootupd_args.push(rootfs_mount);
193 } else {
194 tracing::debug!("bootupd supports --filesystem");
195 bootupd_args.extend(["--filesystem", rootfs_mount]);
196 bootupd_args.push(rootfs_mount);
197 }
198
199 if let Some(deploy) = deployment_path {
204 let target_root = rootfs.join(deploy);
205 let boot_path = rootfs.join("boot");
206 let rootfs_path = rootfs.to_path_buf();
207
208 tracing::debug!("Running bootupctl via chroot in {}", target_root);
209
210 let mut chroot_args = vec!["bootupctl"];
213 chroot_args.extend(bootupd_args);
214
215 let mut cmd = ChrootCmd::new(&target_root)
216 .bind(&boot_path, &"/boot");
219
220 if root_device_path.is_none() {
223 cmd = cmd.bind(&rootfs_path, &"/sysroot");
224 }
225
226 cmd.set_default_path().run(chroot_args)
229 } else {
230 Command::new("bootupctl")
232 .args(&bootupd_args)
233 .log_debug()
234 .run_inherited_with_cmd_context()
235 }
236}
237
238#[context("Installing bootloader")]
240pub(crate) fn install_systemd_boot(
241 prepared_root: &MountedImageRoot,
242 configopts: &crate::install::InstallConfigOpts,
243 autoenroll: Option<SecurebootKeys>,
244) -> Result<()> {
245 println!("Installing bootloader via systemd-boot");
246
247 let root_path = prepared_root
249 .root_path()
250 .to_str()
251 .ok_or_else(|| anyhow::anyhow!("composefs tmpdir path is not UTF-8"))?;
252 let esp_path_in_root = format!("/{}", prepared_root.esp_subdir);
253
254 let mut bootctl_args = vec![
255 "install",
256 "--root",
257 root_path,
258 "--esp-path",
259 esp_path_in_root.as_str(),
260 ];
262
263 if configopts.generic_image {
264 bootctl_args.push("--no-variables");
265 let systemd_version = bootctl_systemd_version()?;
267 if systemd_version >= BOOTCTL_RANDOM_SEED_MIN_VERSION {
268 bootctl_args.extend(["--random-seed", "no"]);
269 } else {
270 tracing::debug!(
271 "Skipping --random-seed: requires systemd >= {BOOTCTL_RANDOM_SEED_MIN_VERSION}, found {systemd_version}"
272 );
273 }
274 }
275
276 Command::new("bootctl")
277 .args(bootctl_args)
278 .env("SYSTEMD_RELAX_ESP_CHECKS", "1")
281 .env("KERNEL_INSTALL_CONF_ROOT", KERNEL_INSTALL_CONF_ROOT)
285 .log_debug()
286 .run_capture_stderr()?;
288
289 if let Some(SecurebootKeys { dir, keys }) = autoenroll {
290 let esp_dir = prepared_root.open_esp_dir()?;
291 let keys_path = prepared_root
292 .root_path()
293 .join(prepared_root.esp_subdir)
294 .join(SYSTEMD_KEY_DIR);
295 create_dir_all(&keys_path).with_context(|| {
296 format!("Creating secureboot key directory {}", keys_path.display())
297 })?;
298
299 let keys_dir = esp_dir
300 .open_dir(SYSTEMD_KEY_DIR)
301 .with_context(|| format!("Opening {SYSTEMD_KEY_DIR}"))?;
302
303 for filename in keys.iter() {
304 if let Some(parent) = filename.parent() {
307 if !parent.as_str().is_empty() {
308 keys_dir
309 .create_dir_all(parent)
310 .with_context(|| format!("Creating key subdirectory {parent}"))?;
311 }
312 }
313 dir.copy(filename, &keys_dir, filename)
314 .with_context(|| format!("Copying secure boot key {filename:?}"))?;
315 println!(
316 "Wrote Secure Boot key: {}/{}",
317 keys_path.display(),
318 filename.as_str()
319 );
320 }
321 if keys.is_empty() {
322 tracing::debug!("No Secure Boot keys provided for systemd-boot enrollment");
323 }
324 }
325
326 Ok(())
327}
328
329#[context("Querying bootctl version")]
330pub(crate) fn bootctl_systemd_version() -> Result<u32> {
331 static VERSION: OnceLock<u32> = OnceLock::new();
332
333 if let Some(v) = VERSION.get() {
334 return Ok(*v);
335 };
336
337 let out = Command::new("bootctl").arg("--version").run_get_string()?;
338 let v = parse_systemd_version(&out).context("Failed to parse version to integer")?;
339
340 let version = VERSION.get_or_init(|| v);
341
342 Ok(*version)
343}
344
345fn parse_systemd_version(output: &str) -> Result<u32> {
348 output
349 .split_whitespace()
350 .nth(1)
351 .and_then(|s| s.parse::<u32>().ok())
352 .ok_or_else(|| {
353 anyhow!("Could not parse systemd version from bootctl --version: {output:?}")
354 })
355}
356
357#[context("Installing bootloader using zipl")]
358pub(crate) fn install_via_zipl(device: &bootc_blockdev::Device, boot_uuid: &str) -> Result<()> {
359 let fs = mount::inspect_filesystem_by_uuid(boot_uuid)?;
361 let boot_dir = Utf8Path::new(&fs.target);
362 let maj_min = fs.maj_min;
363
364 let device_path = device.path();
366
367 let partitions = bootc_blockdev::list_dev(Utf8Path::new(&device_path))?
368 .children
369 .with_context(|| format!("no partition found on {device_path}"))?;
370 let boot_part = partitions
371 .iter()
372 .find(|part| part.maj_min.as_deref() == Some(maj_min.as_str()))
373 .with_context(|| format!("partition device {maj_min} is not on {device_path}"))?;
374 let boot_part_offset = boot_part.start.unwrap_or(0);
375
376 let bls_dir = boot_dir.join("boot/loader/entries");
379 let bls_entry = bls_dir
380 .read_dir_utf8()?
381 .try_fold(None, |acc, e| -> Result<_> {
382 let e = e?;
383 let name = Utf8Path::new(e.file_name());
384 if let Some("conf") = name.extension() {
385 if acc.is_some() {
386 bail!("more than one BLS configurations under {bls_dir}");
387 }
388 Ok(Some(e.path().to_owned()))
389 } else {
390 Ok(None)
391 }
392 })?
393 .with_context(|| format!("no BLS configuration under {bls_dir}"))?;
394
395 let bls_path = bls_dir.join(bls_entry);
396 let bls_conf =
397 std::fs::read_to_string(&bls_path).with_context(|| format!("reading {bls_path}"))?;
398
399 let mut kernel = None;
400 let mut initrd = None;
401 let mut options = None;
402
403 for line in bls_conf.lines() {
404 match line.split_once(char::is_whitespace) {
405 Some(("linux", val)) => kernel = Some(val.trim().trim_start_matches('/')),
406 Some(("initrd", val)) => initrd = Some(val.trim().trim_start_matches('/')),
407 Some(("options", val)) => options = Some(val.trim()),
408 _ => (),
409 }
410 }
411
412 let kernel = kernel.ok_or_else(|| anyhow!("missing 'linux' key in default BLS config"))?;
413 let initrd = initrd.ok_or_else(|| anyhow!("missing 'initrd' key in default BLS config"))?;
414 let options = options.ok_or_else(|| anyhow!("missing 'options' key in default BLS config"))?;
415
416 let image = boot_dir.join(kernel).canonicalize_utf8()?;
417 let ramdisk = boot_dir.join(initrd).canonicalize_utf8()?;
418
419 println!("Running zipl on {device_path}");
421 Command::new("zipl")
422 .args(["--target", boot_dir.as_str()])
423 .args(["--image", image.as_str()])
424 .args(["--ramdisk", ramdisk.as_str()])
425 .args(["--parameters", options])
426 .args(["--targetbase", &device_path])
427 .args(["--targettype", "SCSI"])
428 .args(["--targetblocksize", "512"])
429 .args(["--targetoffset", &boot_part_offset.to_string()])
430 .args(["--add-files", "--verbose"])
431 .log_debug()
432 .run_inherited_with_cmd_context()
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_parse_systemd_version() {
441 let cases = [
443 ("systemd 259 (259.5-0ubuntu3)", 259),
444 ("systemd 257 (257-26.el10-g1d19ad5)", 257),
445 ("systemd 255 (255.4-1ubuntu8.16)", 255),
446 ];
447 for (input, expected) in cases {
448 assert_eq!(
449 parse_systemd_version(input).unwrap(),
450 expected,
451 "input: {input:?}"
452 );
453 }
454 for bad in ["", "systemd", "not a version string"] {
455 assert!(
456 parse_systemd_version(bad).is_err(),
457 "should reject: {bad:?}"
458 );
459 }
460 }
461}