From 3f67be097d455ccb827432ee53927beff45869ae Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 11 Feb 2026 12:11:09 +0100 Subject: [PATCH 1/2] install-to-filesystem: Allow /boot to be missing in target When we're currently checking if /boot is a mountpoint we ware failing if /boot is missing. In the automotive usecase, with aboot, neither /boot or /boot/EFI are real partitions, so when bootc-image-builder runs there will be no mounts anywhere under /boot, which means bootc errors out with: ``` No /boot directory found in root; this is is currently required. ``` This isn't necessary, we can just continue on with boot_is_mount as false in this case. Note: Things later fail in bootupd, but that can be fixed separately. Signed-off-by: Alexander Larsson --- crates/lib/src/install.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index a056d03b7..76b133342 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2410,14 +2410,14 @@ pub(crate) async fn install_to_filesystem( let boot_is_mount = { let root_dev = rootfs_fd.dir_metadata()?.dev(); - let boot_dev = target_rootfs_fd - .symlink_metadata_optional(BOOT)? - .ok_or_else(|| { - anyhow!("No /{BOOT} directory found in root; this is is currently required") - })? - .dev(); - tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}"); - root_dev != boot_dev + if let Some(boot_metadata) = target_rootfs_fd.symlink_metadata_optional(BOOT)? { + let boot_dev = boot_metadata.dev(); + tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}"); + root_dev != boot_dev + } else { + tracing::debug!("No /{BOOT} directory found"); + false + } }; // Find the UUID of /boot because we need it for GRUB. let boot_uuid = if boot_is_mount { From ea65bef23e1aefb9d0174d44a7d64ca0ee14195d Mon Sep 17 00:00:00 2001 From: Javier Martinez Canillas Date: Wed, 11 Feb 2026 13:40:06 +0100 Subject: [PATCH 2/2] install: Add --no-bootloader to skip bootloader setup Currently, the bootc install workflow assumes that the bootloader must be managed by bootupd. This works well for server and edge environments, but it is too inflexible for embedded or custom platforms where the bootloader is managed externally (e.g., aboot for automotive use cases). In these scenarios, users want to install the filesystem content (OSTree commit, kernel, initramfs, etc) without bootc assuming that a boot or ESP partition exists and that bootupd must be invoked to update these. By adding a --no-bootloader option, users can have explicit control over how the boot loading is handled, without bootc or bootupd intervention. Signed-off-by: Javier Martinez Canillas --- crates/lib/src/bootc_composefs/boot.rs | 41 +++++---- crates/lib/src/install.rs | 89 ++++++++++++++----- docs/src/man/bootc-install-to-filesystem.8.md | 5 ++ 3 files changed, 97 insertions(+), 38 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 801a019bd..412e23347 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1231,7 +1231,12 @@ pub(crate) async fn setup_composefs_boot( .or(root_setup.rootfs_uuid.as_deref()) .ok_or_else(|| anyhow!("No uuid for boot/root"))?; - if cfg!(target_arch = "s390x") { + // If bootloader is disabled, we skip the installer calls + let skip_bootloader = state.config_opts.no_bootloader; + + if skip_bootloader { + tracing::info!("Skipping bootloader installation (--no-bootloader requested)"); + } else if cfg!(target_arch = "s390x") { // TODO: Integrate s390x support into install_via_bootupd crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?; } else if postfetch.detected_bootloader == Bootloader::Grub { @@ -1257,20 +1262,26 @@ pub(crate) async fn setup_composefs_boot( let boot_type = BootType::from(entry); - let boot_digest = match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), - repo, - &id, - entry, - &mounted_fs, - )?, - BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), - repo, - &id, - entries, - )?, + // We calculate the digest only if we are actually setting up the boot files. + // If we skip, we use a placeholder digest for the state file. + let boot_digest = if skip_bootloader { + "skipped".to_string() + } else { + match boot_type { + BootType::Bls => setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + repo, + &id, + entry, + &mounted_fs, + )?, + BootType::Uki => setup_composefs_uki_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + repo, + &id, + entries, + )?, + } }; write_composefs_state( diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 76b133342..5ceab64e5 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -373,6 +373,12 @@ pub(crate) struct InstallConfigOpts { #[clap(long)] #[serde(default)] pub(crate) bootupd_skip_boot_uuid: bool, + + /// Disable all bootloader integration and requirements + // This allows installing to a filesystem without boot and ESP partitions. + #[clap(long)] + #[serde(default)] + pub(crate) no_bootloader: bool, } #[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] @@ -1737,30 +1743,35 @@ async fn install_with_sysroot( .physical_root .open_dir(&deployment_path) .context("Opening deployment dir")?; - let postfetch = PostFetchState::new(state, &deployment_dir)?; - if cfg!(target_arch = "s390x") { - // TODO: Integrate s390x support into install_via_bootupd - crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?; + if state.config_opts.no_bootloader { + tracing::debug!("Skipping bootloader installation (--no-bootloader requested)"); } else { - match postfetch.detected_bootloader { - Bootloader::Grub => { - crate::bootloader::install_via_bootupd( - &rootfs.device_info, - &rootfs - .target_root_path - .clone() - .unwrap_or(rootfs.physical_root_path.clone()), - &state.config_opts, - Some(&deployment_path.as_str()), - )?; - } - Bootloader::Systemd => { - anyhow::bail!("bootupd is required for ostree-based installs"); + let postfetch = PostFetchState::new(state, &deployment_dir)?; + + if cfg!(target_arch = "s390x") { + // TODO: Integrate s390x support into install_via_bootupd + crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?; + } else { + match postfetch.detected_bootloader { + Bootloader::Grub => { + crate::bootloader::install_via_bootupd( + &rootfs.device_info, + &rootfs + .target_root_path + .clone() + .unwrap_or(rootfs.physical_root_path.clone()), + &state.config_opts, + Some(&deployment_path.as_str()), + )?; + } + Bootloader::Systemd => { + anyhow::bail!("bootupd is required for ostree-based installs"); + } } } + tracing::debug!("Installed bootloader"); } - tracing::debug!("Installed bootloader"); tracing::debug!("Performing post-deployment operations"); @@ -1890,7 +1901,11 @@ async fn install_to_filesystem_impl( let (id, verity) = initialize_composefs_repository(state, rootfs).await?; tracing::info!("id: {id}, verity: {}", verity.to_hex()); - setup_composefs_boot(rootfs, state, &id).await?; + if state.config_opts.no_bootloader { + tracing::debug!("Skipping setup composefs boot (--no-bootloader requested)"); + } else { + setup_composefs_boot(rootfs, state, &id).await?; + } } else { ostree_install(state, rootfs, cleanup).await?; } @@ -2138,7 +2153,16 @@ fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> { } #[context("Removing boot directory content")] -fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) -> Result<()> { +fn clean_boot_directories( + rootfs: &Dir, + rootfs_path: &Utf8Path, + is_ostree: bool, + skip: bool, +) -> Result<()> { + if skip { + return Ok(()); + } + let bootdir = crate::utils::open_dir_remount_rw(rootfs, BOOT.into()).context("Opening /boot")?; @@ -2359,7 +2383,13 @@ pub(crate) async fn install_to_filesystem( .await??; } Some(ReplaceMode::Alongside) => { - clean_boot_directories(&target_rootfs_fd, &target_root_path, is_already_ostree)? + let skip_cleaning = state.config_opts.no_bootloader; + clean_boot_directories( + &target_rootfs_fd, + &target_root_path, + is_already_ostree, + skip_cleaning, + )? } None => require_empty_rootdir(&rootfs_fd)?, } @@ -2461,7 +2491,10 @@ pub(crate) async fn install_to_filesystem( .install_config .as_ref() .and_then(|c| c.boot_mount_spec.as_ref()); - let mut boot = if let Some(spec) = fsopts.boot_mount_spec.as_ref().or(config_boot_mount_spec) { + + let mut boot = if state.config_opts.no_bootloader { + None + } else if let Some(spec) = fsopts.boot_mount_spec.as_ref().or(config_boot_mount_spec) { // An empty boot mount spec signals to omit the mountspec kargs // See https://github.com/bootc-dev/bootc/issues/1441 if spec.is_empty() { @@ -2963,4 +2996,14 @@ UUID=boot-uuid /boot ext4 defaults 0 0 Ok(()) } + + #[test] + fn test_clean_boot_directories_explicit_skip() -> Result<()> { + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let root_path = Utf8Path::new("/target"); + // Case: Explicit Skip should succeed even if /boot doesn't exist + let res = clean_boot_directories(&td, root_path, false, true); + assert!(res.is_ok()); + Ok(()) + } } diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index de8d0af9e..2f0bc9844 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -49,6 +49,11 @@ is currently expected to be empty by default. The default mode is to "finalize" the target filesystem by invoking `fstrim` and similar operations, and finally mounting it readonly. This option skips those operations. It is then the responsibility of the invoking code to perform those operations +**--no-bootloader** + + Disable all bootloader integration and requirements. This allows installing to a + filesystem without boot and ESP partitions + **--source-imgref**=*SOURCE_IMGREF* Install the system from an explicitly given source