From f4c678eb88d047b5ffe1391f9d40ec64a11dc11e Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 2 Oct 2025 02:32:52 +0200 Subject: [PATCH] Various composefs enhancements - Change the install logic to detect UKIs and automatically enable composefs - Change the install logic to detect absence of bootupd and default to installing systemd-boot - Move sealing bits to the toplevel - Add Justfile entrypoints - Add basic end-to-end CI coverage (install + run) using our integration tests - Change lints to ignore `/boot/EFI` Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 21 ++++ Cargo.lock | 4 +- Dockerfile | 10 ++ Dockerfile.cfsuki | 73 ++++++++++++++ Justfile | 9 ++ crates/lib/src/bootc_composefs/boot.rs | 99 +++++++++++++------ crates/lib/src/bootloader.rs | 61 +++++++++++- crates/lib/src/cli.rs | 63 +++++++++++- crates/lib/src/generator.rs | 1 - crates/lib/src/install.rs | 89 +++++++++++++---- crates/lib/src/install/baseline.rs | 2 +- crates/lib/src/lints.rs | 32 +++++- crates/lib/src/podstorage.rs | 11 +++ crates/lib/src/store/mod.rs | 1 + crates/lib/src/utils.rs | 20 ++++ crates/tests-integration/README.md | 5 + .../tests-integration/src/composefs_bcvk.rs | 79 +++++++++++++++ .../src/tests-integration.rs | 7 ++ crates/xtask/src/xtask.rs | 9 ++ docs/src/bootloaders.md | 16 ++- tests/build-sealed | 46 +++++++++ 21 files changed, 600 insertions(+), 58 deletions(-) create mode 100644 Dockerfile.cfsuki create mode 100644 crates/tests-integration/src/composefs_bcvk.rs create mode 100755 tests/build-sealed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d4ec092..977a1e8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,3 +192,24 @@ jobs: with: name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-${{ env.ARCH }}-${{ matrix.tmt_plan }} path: /var/tmp/tmt + # This variant does composefs testing + test-integration-cfs: + strategy: + fail-fast: false + matrix: + test_os: [centos-10] + + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + - name: Bootc Ubuntu Setup + uses: ./.github/actions/bootc-ubuntu-setup + with: + libvirt: true + + - name: Build container + run: just build-sealed + + - name: Test + run: just test-composefs diff --git a/Cargo.lock b/Cargo.lock index 83dc5064..c4676730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,9 +487,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" diff --git a/Dockerfile b/Dockerfile index 15e81253..9f7368bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,11 +91,21 @@ RUN --mount=type=cache,target=/build/target --mount=type=cache,target=/var/rooth # The final image that derives from the original base and adds the release binaries FROM base +# Set this to 1 to default to systemd-boot +ARG sdboot=0 RUN < to use a different base +ARG base=localhost/bootc +# This is where we get the tools to build the UKI +ARG buildroot=quay.io/fedora/fedora:42 +FROM $base AS base + +FROM $buildroot as buildroot-base +RUN < Result { + let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else { + return Ok(false); + }; + let Some(efi_linux) = boot.open_dir_optional(EFI_LINUX)? else { + return Ok(false); + }; + for entry in efi_linux.entries()? { + let entry = entry?; + let name = entry.file_name(); + let name = Path::new(&name); + let extension = name.extension().and_then(|v| v.to_str()); + if extension == Some("efi") { + return Ok(true); + } + } + Ok(false) +} + pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { let device_info = bootc_blockdev::partitions_of(Utf8Path::new(device))?; - let esp = device_info - .partitions - .into_iter() - .find(|p| p.parttype.as_str() == ESP_GUID) - .ok_or(anyhow::anyhow!("ESP not found for device: {device}"))?; + let esp = crate::bootloader::esp_in(&device_info)?; - Ok((esp.node, esp.uuid)) + Ok((esp.node.clone(), esp.uuid.clone())) } pub fn get_sysroot_parent_dev() -> Result { @@ -360,23 +379,14 @@ pub(crate) fn setup_composefs_bls_boot( }; // Locate ESP partition device - let esp_part = root_setup - .device_info - .partitions - .iter() - .find(|p| p.parttype.as_str() == ESP_GUID) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let esp_part = esp_in(&root_setup.device_info)?; ( root_setup.physical_root_path.clone(), esp_part.node.clone(), cmdline_options, fs, - state - .composefs_options - .as_ref() - .map(|opts| opts.bootloader.clone()) - .unwrap_or(Bootloader::default()), + state.detected_bootloader.clone(), ) } @@ -829,17 +839,12 @@ pub(crate) fn setup_composefs_uki_boot( anyhow::bail!("ComposeFS options not found"); }; - let esp_part = root_setup - .device_info - .partitions - .iter() - .find(|p| p.parttype.as_str() == ESP_GUID) - .ok_or_else(|| anyhow!("ESP partition not found"))?; + let esp_part = esp_in(&root_setup.device_info)?; ( root_setup.physical_root_path.clone(), esp_part.node.clone(), - cfs_opts.bootloader.clone(), + state.detected_bootloader.clone(), cfs_opts.insecure, cfs_opts.uki_addon.as_ref(), ) @@ -944,13 +949,20 @@ pub(crate) fn setup_composefs_boot( 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 { + } else if state.detected_bootloader == Bootloader::Grub { crate::bootloader::install_via_bootupd( &root_setup.device_info, &root_setup.physical_root_path, &state.config_opts, None, )?; + } else { + crate::bootloader::install_systemd_boot( + &root_setup.device_info, + &root_setup.physical_root_path, + &state.config_opts, + None, + )?; } let repo = open_composefs_repo(&root_setup.physical_root)?; @@ -1001,3 +1013,34 @@ pub(crate) fn setup_composefs_boot( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std; + + #[test] + fn test_root_has_uki() -> Result<()> { + // Test case 1: No boot directory + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 2: boot directory exists but no EFI/Linux + tempdir.create_dir(crate::install::BOOT)?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 3: boot/EFI/Linux exists but no .efi files + tempdir.create_dir_all("boot/EFI/Linux")?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 4: boot/EFI/Linux exists with non-.efi file + tempdir.atomic_write("boot/EFI/Linux/readme.txt", b"some file")?; + assert_eq!(container_root_has_uki(&tempdir)?, false); + + // Test case 5: boot/EFI/Linux exists with .efi file + tempdir.atomic_write("boot/EFI/Linux/bootx64.efi", b"fake efi binary")?; + assert_eq!(container_root_has_uki(&tempdir)?, true); + + Ok(()) + } +} diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 0f02198a..3e65b29a 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -5,11 +5,45 @@ use bootc_utils::CommandRunExt; use camino::Utf8Path; use fn_error_context::context; -use bootc_blockdev::PartitionTable; +use bootc_blockdev::{Partition, PartitionTable}; use bootc_mount as mount; +use bootc_mount::tempmount::TempMount; + +use crate::utils; /// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel) pub(crate) const EFI_DIR: &str = "efi"; +/// The EFI system partition GUID +#[allow(dead_code)] +pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; +/// Path to the bootupd update payload +#[allow(dead_code)] +const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates"; + +#[allow(dead_code)] +pub(crate) fn esp_in(device: &PartitionTable) -> Result<&Partition> { + device + .partitions + .iter() + .find(|p| p.parttype.as_str() == ESP_GUID) + .ok_or(anyhow::anyhow!("ESP not found in partition table")) +} + +/// Determine if the invoking environment contains bootupd, and if there are bootupd-based +/// updates in the target root. +#[context("Querying for bootupd")] +#[allow(dead_code)] +pub(crate) fn supports_bootupd(deployment_path: Option<&str>) -> Result { + if !utils::have_executable("bootupctl")? { + tracing::trace!("No bootupctl binary found"); + return Ok(false); + }; + let deployment_path = Utf8Path::new(deployment_path.unwrap_or("/")); + let updates = deployment_path.join(BOOTUPD_UPDATES); + let r = updates.try_exists()?; + tracing::trace!("bootupd updates: {r}"); + Ok(r) +} #[context("Installing bootloader")] pub(crate) fn install_via_bootupd( @@ -40,6 +74,31 @@ pub(crate) fn install_via_bootupd( .run_inherited_with_cmd_context() } +#[context("Installing bootloader")] +#[cfg(any(feature = "composefs-backend", feature = "install-to-disk"))] +pub(crate) fn install_systemd_boot( + device: &PartitionTable, + _rootfs: &Utf8Path, + _configopts: &crate::install::InstallConfigOpts, + _deployment_path: Option<&str>, +) -> Result<()> { + let esp_part = device + .partitions + .iter() + .find(|p| p.parttype.as_str() == ESP_GUID) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + + let esp_mount = TempMount::mount_dev(&esp_part.node).context("Mounting ESP")?; + let esp_path = Utf8Path::from_path(esp_mount.dir.path()) + .ok_or_else(|| anyhow::anyhow!("Failed to convert ESP mount path to UTF-8"))?; + + println!("Installing bootloader via systemd-boot"); + Command::new("bootctl") + .args(["install", "--esp-path", esp_path.as_str()]) + .log_debug() + .run_inherited_with_cmd_context() +} + #[context("Installing bootloader using zipl")] pub(crate) fn install_via_zipl(device: &PartitionTable, boot_uuid: &str) -> Result<()> { // Identify the target boot partition from UUID diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 3412a6b0..f3737b37 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -6,13 +6,15 @@ use std::ffi::{CString, OsStr, OsString}; use std::io::Seek; use std::os::unix::process::CommandExt; use std::process::Command; +use std::sync::Arc; use anyhow::{ensure, Context, Result}; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::cap_std; use cap_std_ext::cap_std::fs::Dir; use clap::Parser; use clap::ValueEnum; +use composefs_boot::BootOps as _; use etc_merge::{compute_diff, print_diff}; use fn_error_context::context; use indoc::indoc; @@ -23,11 +25,13 @@ use ostree_ext::composefs::fsverity::FsVerityHashValue; use ostree_ext::composefs::splitstream::SplitStreamWriter; use ostree_ext::container as ostree_container; use ostree_ext::container_utils::ostree_booted; +use ostree_ext::containers_image_proxy::ImageProxyConfig; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; use ostree_ext::sysroot::SysrootLock; use schemars::schema_for; use serde::{Deserialize, Serialize}; +use tempfile::tempdir_in; #[cfg(feature = "composefs-backend")] use crate::bootc_composefs::{ @@ -40,9 +44,11 @@ use crate::bootc_composefs::{ }; use crate::deploy::RequiredHostSpec; use crate::lints; +use crate::podstorage::set_additional_image_store; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; use crate::spec::ImageReference; +use crate::store::ComposefsRepository; use crate::utils::sigpolicy_from_opt; /// Shared progress options @@ -315,6 +321,12 @@ pub(crate) enum ContainerOpts { #[clap(long)] no_truncate: bool, }, + /// Output the bootable composefs digest. + #[clap(hide = true)] + ComputeComposefsDigest { + /// Identifier for image; if not provided, the running image will be used. + image: Option, + }, } /// Subcommands which operate on images. @@ -1335,6 +1347,55 @@ async fn run_from_opt(opt: Opt) -> Result<()> { )?; Ok(()) } + ContainerOpts::ComputeComposefsDigest { image } => { + // Allocate a tempdir + let td = tempdir_in("/var/tmp")?; + let td = td.path(); + let td = &Dir::open_ambient_dir(td, cap_std::ambient_authority())?; + + td.create_dir("repo")?; + let repo = td.open_dir("repo")?; + let mut repo = + ComposefsRepository::open_path(&repo, ".").context("Init cfs repo")?; + // We don't need to hard require verity on the *host* system, we're just computing a checksum here + repo.set_insecure(true); + let repo = &Arc::new(repo); + + let mut proxycfg = ImageProxyConfig::default(); + + let image = if let Some(image) = image { + image + } else { + let host_container_store = Utf8Path::new("/run/host-container-storage"); + // If no image is provided, assume that we're running in a container in privileged mode + // with access to the container storage. + let container_info = crate::containerenv::get_container_execution_info(&root)?; + let iid = container_info.imageid; + tracing::debug!("Computing digest of {iid}"); + + if !host_container_store.try_exists()? { + anyhow::bail!("Must be readonly mount of host container store: {host_container_store}"); + } + // And ensure we're finding the image in the host storage + let mut cmd = Command::new("skopeo"); + set_additional_image_store(&mut cmd, "/run/host-container-storage"); + proxycfg.skopeo_cmd = Some(cmd); + iid + }; + + let imgref = format!("containers-storage:{image}"); + let (imgid, verity) = composefs_oci::pull(repo, &imgref, None, Some(proxycfg)) + .await + .context("Pulling image")?; + let imgid = hex::encode(imgid); + let mut fs = composefs_oci::image::create_filesystem(repo, &imgid, Some(&verity)) + .context("Populating fs")?; + fs.transform_for_boot(&repo).context("Preparing for boot")?; + let id = fs.compute_image_id(); + println!("{}", id.to_hex()); + + Ok(()) + } }, Opt::Image(opts) => match opts { ImageOpts::List { diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index 9f28e3a3..a2e75318 100644 --- a/crates/lib/src/generator.rs +++ b/crates/lib/src/generator.rs @@ -139,7 +139,6 @@ ExecStart=bootc internals fixup-etc-fstab\n\ #[cfg(test)] mod tests { use camino::Utf8Path; - use cap_std_ext::cmdext::CapStdExtCommandExt as _; use super::*; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index defa9d19..5d759fe9 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -70,7 +70,7 @@ use bootc_mount::Filesystem; use composefs::fsverity::FsVerityHashValue; /// The toplevel boot directory -const BOOT: &str = "boot"; +pub(crate) const BOOT: &str = "boot"; /// Directory for transient runtime state #[cfg(feature = "install-to-disk")] const RUN_BOOTC: &str = "/run/bootc"; @@ -87,8 +87,6 @@ const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi pub(crate) const EFIVARFS: &str = "/sys/firmware/efi/efivars"; pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); -#[cfg(any(feature = "composefs-backend", feature = "install-to-disk"))] -pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; #[cfg(any(feature = "composefs-backend", feature = "install-to-disk"))] // Architecture-specific DPS UUIDs for install-to-disk flow @@ -247,15 +245,15 @@ pub(crate) struct InstallConfigOpts { pub(crate) stateroot: Option, } -#[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallComposefsOpts { #[clap(long, default_value_t)] #[serde(default)] pub(crate) insecure: bool, - #[clap(long, default_value_t)] + #[clap(long)] #[serde(default)] - pub(crate) bootloader: Bootloader, + pub(crate) bootloader: Option, /// Name of the UKI addons to install without the ".efi.addon" suffix. /// This option can be provided multiple times if multiple addons are to be installed. @@ -438,9 +436,16 @@ pub(crate) struct State { pub(crate) container_root: Dir, pub(crate) tempdir: TempDir, + /// Set if we have determined that composefs is required + #[allow(dead_code)] + pub(crate) composefs_required: bool, + // If Some, then --composefs_native is passed #[cfg(feature = "composefs-backend")] pub(crate) composefs_options: Option, + + /// Detected bootloader type for the target system + pub(crate) detected_bootloader: crate::spec::Bootloader, } impl State { @@ -793,6 +798,7 @@ async fn install_container( let sepolicy = sepolicy.as_ref(); let stateroot = state.stateroot(); + // TODO factor out this let (src_imageref, proxy_cfg) = if !state.source.in_host_mountns { (state.source.imageref.clone(), None) } else { @@ -1221,12 +1227,20 @@ async fn verify_target_fetch( Ok(()) } +fn root_has_uki(root: &Dir) -> Result { + #[cfg(feature = "composefs-backend")] + return crate::bootc_composefs::boot::container_root_has_uki(root); + + #[cfg(not(feature = "composefs-backend"))] + Ok(false) +} + /// Preparation for an install; validates and prepares some (thereafter immutable) global state. async fn prepare_install( config_opts: InstallConfigOpts, source_opts: InstallSourceOpts, target_opts: InstallTargetOpts, - _composefs_opts: Option, + composefs_options: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1234,7 +1248,7 @@ async fn prepare_install( let host_is_container = crate::containerenv::is_container(&rootfs); let external_source = source_opts.source_imgref.is_some(); - let source = match source_opts.source_imgref { + let (source, target_rootfs) = match source_opts.source_imgref { None => { ensure!(host_is_container, "Either --source-imgref must be defined or this command must be executed inside a podman container."); @@ -1259,11 +1273,13 @@ async fn prepare_install( }; tracing::trace!("Read container engine info {:?}", container_info); - SourceInfo::from_container(&rootfs, &container_info)? + let source = SourceInfo::from_container(&rootfs, &container_info)?; + (source, Some(rootfs.try_clone()?)) } Some(source) => { crate::cli::require_root(false)?; - SourceInfo::from_imageref(&source, &rootfs)? + let source = SourceInfo::from_imageref(&source, &rootfs)?; + (source, None) } }; @@ -1291,6 +1307,15 @@ async fn prepare_install( }; tracing::debug!("Target image reference: {target_imgref}"); + let composefs_required = if let Some(root) = target_rootfs.as_ref() { + root_has_uki(root)? + } else { + false + }; + tracing::debug!("Composefs required: {composefs_required}"); + let composefs_options = + composefs_options.or_else(|| composefs_required.then_some(InstallComposefsOpts::default())); + // We need to access devices that are set up by the host udev bootc_mount::ensure_mirrored_host_mount("/dev")?; // We need to read our own container image (and any logically bound images) @@ -1357,6 +1382,27 @@ async fn prepare_install( .map(|p| std::fs::read_to_string(p).with_context(|| format!("Reading {p}"))) .transpose()?; + // Determine bootloader type for the target system + // Priority: user-specified > bootupd availability > systemd-boot fallback + #[cfg(feature = "composefs-backend")] + let detected_bootloader = { + if let Some(bootloader) = composefs_options + .as_ref() + .and_then(|opts| opts.bootloader.clone()) + { + bootloader + } else { + if crate::bootloader::supports_bootupd(None)? { + crate::spec::Bootloader::Grub + } else { + crate::spec::Bootloader::Systemd + } + } + }; + #[cfg(not(feature = "composefs-backend"))] + let detected_bootloader = crate::spec::Bootloader::Grub; + println!("Bootloader: {detected_bootloader}"); + // Create our global (read-only) state which gets wrapped in an Arc // so we can pass it to worker threads too. Right now this just // combines our command line options along with some bind mounts from the host. @@ -1371,8 +1417,10 @@ async fn prepare_install( container_root: rootfs, tempdir, host_is_container, + composefs_required, #[cfg(feature = "composefs-backend")] - composefs_options: _composefs_opts, + composefs_options, + detected_bootloader, }); Ok(state) @@ -1405,12 +1453,19 @@ async fn install_with_sysroot( // TODO: Integrate s390x support into install_via_bootupd crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?; } else { - crate::bootloader::install_via_bootupd( - &rootfs.device_info, - &rootfs.physical_root_path, - &state.config_opts, - Some(&deployment_path.as_str()), - )?; + match state.detected_bootloader { + Bootloader::Grub => { + crate::bootloader::install_via_bootupd( + &rootfs.device_info, + &rootfs.physical_root_path, + &state.config_opts, + Some(&deployment_path.as_str()), + )?; + } + Bootloader::Systemd => { + anyhow::bail!("bootupd is required for ostree-based installs"); + } + } } tracing::debug!("Installed bootloader"); diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 685e6963..4b6a8ede 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -275,7 +275,7 @@ pub(crate) fn install_create_rootfs( } let esp_partno = if super::ARCH_USES_EFI { - let esp_guid = crate::install::ESP_GUID; + let esp_guid = crate::bootloader::ESP_GUID; partno += 1; writeln!( &mut partitioning_buf, diff --git a/crates/lib/src/lints.rs b/crates/lib/src/lints.rs index 0e4a0416..55b4f197 100644 --- a/crates/lib/src/lints.rs +++ b/crates/lib/src/lints.rs @@ -27,6 +27,9 @@ use linkme::distributed_slice; use ostree_ext::ostree_prepareroot; use serde::Serialize; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::boot::EFI_LINUX; + /// Reference to embedded default baseimage content that should exist. const BASEIMAGE_REF: &str = "usr/share/doc/bootc/baseimage/base"; // https://systemd.io/API_FILE_SYSTEMS/ with /var added for us @@ -758,14 +761,27 @@ fn check_boot(root: &Dir, config: &LintExecutionConfig) -> LintResult { }; // First collect all entries to determine if the directory is empty - let entries: Result, _> = d.entries()?.collect(); - let entries = entries?; + let entries: Result, _> = d + .entries()? + .into_iter() + .map(|v| { + let v = v?; + anyhow::Ok(v.file_name()) + }) + .collect(); + let mut entries = entries?; + #[cfg(feature = "composefs-backend")] + { + // Work around https://github.com/containers/composefs-rs/issues/131 + let efidir = Utf8Path::new(EFI_LINUX) + .parent() + .map(|b| b.as_std_path()) + .unwrap(); + entries.remove(efidir.as_os_str()); + } if entries.is_empty() { return lint_ok(); } - // Gather sorted filenames - let mut entries = entries.iter().map(|v| v.file_name()).collect::>(); - entries.sort(); let header = "Found non-empty /boot"; let items = entries.iter().map(PathQuotedDisplay::new); @@ -973,6 +989,12 @@ mod tests { let root = &passing_fixture()?; let config = &LintExecutionConfig::default(); check_boot(&root, config).unwrap().unwrap(); + + // Verify creating EFI doesn't error + root.create_dir_all("EFI/Linux")?; + root.write("EFI/Linux/foo.efi", b"some dummy efi")?; + check_boot(&root, config).unwrap().unwrap(); + root.create_dir("boot/somesubdir")?; let Err(e) = check_boot(&root, config).unwrap() else { unreachable!() diff --git a/crates/lib/src/podstorage.rs b/crates/lib/src/podstorage.rs index 6ac8b0a5..eaff5657 100644 --- a/crates/lib/src/podstorage.rs +++ b/crates/lib/src/podstorage.rs @@ -127,6 +127,17 @@ fn new_podman_cmd_in(storage_root: &Dir, run_root: &Dir) -> Result { Ok(cmd) } +/// Adjust the provided command (skopeo or podman e.g.) to reference +/// the provided path as an additional image store. +pub fn set_additional_image_store<'c>( + cmd: &'c mut Command, + ais: impl AsRef, +) -> &'c mut Command { + let ais = ais.as_ref(); + let storage_opt = format!("additionalimagestore={ais}"); + cmd.env("STORAGE_OPTS", storage_opt) +} + /// Ensure that "podman" is the first thing to touch the global storage /// instance. This is a workaround for https://github.com/bootc-dev/bootc/pull/1101#issuecomment-2653862974 /// Basically podman has special upgrade logic for when it is the first thing diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 8ce41f54..7dfbafb2 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -37,6 +37,7 @@ use crate::utils::deployment_fd; /// See https://github.com/containers/composefs-rs/issues/159 pub type ComposefsRepository = composefs::repository::Repository; +#[cfg(feature = "composefs-backend")] pub type ComposefsFilesystem = composefs::tree::FileSystem; /// Path to the physical root diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index 8f5664a4..59d37774 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -68,6 +68,20 @@ pub(crate) fn find_mount_option<'a>( .next() } +#[allow(dead_code)] +pub fn have_executable(name: &str) -> Result { + let Some(path) = std::env::var_os("PATH") else { + return Ok(false); + }; + for mut elt in std::env::split_paths(&path) { + elt.push(name); + if elt.try_exists()? { + return Ok(true); + } + } + Ok(false) +} + /// Given a target directory, if it's a read-only mount, then remount it writable #[context("Opening {target} with writable mount")] pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result { @@ -323,4 +337,10 @@ mod tests { "Paths must be absolute" ); } + + #[test] + fn test_have_executable() { + assert!(have_executable("true").unwrap()); + assert!(!have_executable("someexethatdoesnotexist").unwrap()); + } } diff --git a/crates/tests-integration/README.md b/crates/tests-integration/README.md index f5ac740d..9299670a 100644 --- a/crates/tests-integration/README.md +++ b/crates/tests-integration/README.md @@ -13,6 +13,11 @@ run a `podman build` with the bootc git sources. ## Available suites +### `composefs-bcvk` + +Intended only right now to be used with a sealed UKI image, +and sanity checks the composefs backend. + ### `host-privileged` This suite will run the target container image in a way that expects diff --git a/crates/tests-integration/src/composefs_bcvk.rs b/crates/tests-integration/src/composefs_bcvk.rs new file mode 100644 index 00000000..0dd385ef --- /dev/null +++ b/crates/tests-integration/src/composefs_bcvk.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use camino::Utf8Path; +use libtest_mimic::Trial; +use xshell::{cmd, Shell}; + +const BOOTED: &str = ""; + +fn outer_runner(image: &'static str) -> Vec { + [Trial::test("Basic", move || { + let sh = &xshell::Shell::new()?; + const NAME: &str = "bootc-composefs-bcvk-test"; + struct StopTestVM<'a>(&'a Shell); + impl<'a> Drop for StopTestVM<'a> { + fn drop(&mut self) { + let _ = cmd!(self.0, "bcvk libvirt rm --stop --force {NAME}") + .ignore_status() + .ignore_stdout() + .ignore_stderr() + .quiet() + .run(); + } + } + // Clean up any leakage if e.g. the whole process died + drop(StopTestVM(sh)); + // And also do so on drop + let _guard = StopTestVM(sh); + cmd!( + sh, + "bcvk libvirt run --name {NAME} --filesystem=ext4 --firmware=uefi-insecure {image}" + ) + .run()?; + for _ in 0..5 { + if cmd!(sh, "bcvk libvirt ssh {NAME} -- true") + .ignore_stderr() + .run() + .is_ok() + { + break; + } + } + cmd!( + sh, + "bcvk libvirt ssh {NAME} -- bootc-integration-tests composefs-bcvk {BOOTED}" + ) + .run()?; + Ok(()) + })] + .into_iter() + .collect() +} + +fn inner_tests() -> Vec { + [Trial::test("Basic", move || { + let sh = &xshell::Shell::new()?; + let st = cmd!(sh, "bootc status --json").read()?; + let st: serde_json::Value = serde_json::from_str(&st)?; + assert!(st.is_object()); + assert!(Utf8Path::new("/sysroot/composefs").try_exists()?); + assert!(!Utf8Path::new("/sysroot/ostree").try_exists()?); + Ok(()) + })] + .into_iter() + .collect() +} + +//#[context("Composefs+bcvk tests")] +pub(crate) fn run(image: &str, testargs: libtest_mimic::Arguments) -> Result<()> { + // Just leak the image name so we get a static reference as required by the test framework + let image: &'static str = String::from(image).leak(); + // Handy defaults + + let tests = if image == BOOTED { + inner_tests() + } else { + outer_runner(image) + }; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/tests-integration.rs b/crates/tests-integration/src/tests-integration.rs index d412ae39..477bd0d0 100644 --- a/crates/tests-integration/src/tests-integration.rs +++ b/crates/tests-integration/src/tests-integration.rs @@ -4,6 +4,7 @@ use camino::Utf8PathBuf; use cap_std_ext::cap_std::{self, fs::Dir}; use clap::Parser; +mod composefs_bcvk; mod container; mod hostpriv; mod install; @@ -31,6 +32,11 @@ pub(crate) enum Opt { #[clap(flatten)] testargs: libtest_mimic::Arguments, }, + ComposefsBcvk { + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, /// Tests which should be executed inside an existing bootc container image. /// These should be nondestructive. Container { @@ -55,6 +61,7 @@ fn main() { Opt::SystemReinstall { image, testargs } => system_reinstall::run(&image, testargs), Opt::InstallAlongside { image, testargs } => install::run_alongside(&image, testargs), Opt::HostPrivileged { image, testargs } => hostpriv::run_hostpriv(&image, testargs), + Opt::ComposefsBcvk { image, testargs } => composefs_bcvk::run(&image, testargs), Opt::Container { testargs } => container::run(testargs), Opt::RunVM(opts) => runvm::run(opts), Opt::VerifySELinux { rootfs, warn } => { diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 5f04395d..e5281b5a 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -11,6 +11,7 @@ use std::process::Command; use anyhow::{Context, Result}; use camino::{Utf8Path, Utf8PathBuf}; use fn_error_context::context; +use serde::Deserialize; use xshell::{cmd, Shell}; mod man; @@ -237,6 +238,14 @@ fn spec(sh: &Shell) -> Result<()> { Ok(()) } +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +#[serde(rename_all = "PascalCase")] +struct ImageInspect { + pub id: String, + pub digest: String, +} + fn impl_srpm(sh: &Shell) -> Result { { let _g = sh.push_dir("target"); diff --git a/docs/src/bootloaders.md b/docs/src/bootloaders.md index 0cf4e8f9..664c9a5a 100644 --- a/docs/src/bootloaders.md +++ b/docs/src/bootloaders.md @@ -1,9 +1,21 @@ # Bootloaders in `bootc` -`bootc` uses [bootupd](https://github.com/coreos/bootupd/) by default to manage bootloader installation and configuration. `bootupd` is an external project that abstracts over bootloader installs and upgrades, providing a consistent interface for different bootloader types (e.g., GRUB, systemd-boot). +`bootc` supports two ways to manage bootloaders. + +## bootupd + +[bootupd](https://github.com/coreos/bootupd/) is a project explicitly designed to abstract over and manage bootloader installation and configuration. +Today it primarily supports GRUB+shim. There are pending patches for it to support systemd-boot as well. When you run `bootc install`, it invokes `bootupctl backend install` to install the bootloader to the target disk or filesystem. The specific bootloader configuration is determined by the container image and the target system's hardware. Currently, `bootc` only runs `bootupd` during the installation process. It does **not** automatically run `bootupctl update` to update the bootloader after installation. This means that bootloader updates must be handled separately, typically by the user or an automated system update process. -For s390x, bootc uses `zipl` instead of `bootupd`. \ No newline at end of file +## systemd-boot + +If bootupd is not present in the input container image, then systemd-boot will be used +by default (except on s390x). + +## s390x + +bootc uses `zipl`. diff --git a/tests/build-sealed b/tests/build-sealed new file mode 100755 index 00000000..c69214f7 --- /dev/null +++ b/tests/build-sealed @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail +# This should turn into https://github.com/bootc-dev/bootc/issues/1498 + +# The un-sealed container image we want to use +input_image=$1 +shift +# The output container image +output_image=$1 +shift +# Optional directory with secure boot keys; if none are provided, then we'll +# generate some under target/ +secureboot=${1:-} + +runv() { + set +x + "$@" +} + +graphroot=$(podman system info -f '{{.Store.GraphRoot}}') +echo "Computing composefs digest..." +cfs_digest=$(podman run --rm --privileged --read-only --security-opt=label=disable -v /sys:/sys:ro --net=none \ + -v ${graphroot}:/run/host-container-storage:ro --tmpfs /var "$input_image" bootc container compute-composefs-digest) + +if test -z "${secureboot}"; then + secureboot=$(pwd)/target/test-secureboot + mkdir -p ${secureboot} + cd $secureboot + if test '!' -f db.cer; then + echo "Generating test Secure Boot keys" + uuidgen --random > GUID.txt + openssl req -quiet -newkey rsa:4096 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 -subj '/CN=Test Platform Key/' -out PK.crt + openssl x509 -outform DER -in PK.crt -out PK.cer + openssl req -quiet -newkey rsa:4096 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 -subj '/CN=Test Key Exchange Key/' -out KEK.crt + openssl x509 -outform DER -in KEK.crt -out KEK.cer + openssl req -quiet -newkey rsa:4096 -nodes -keyout db.key -new -x509 -sha256 -days 3650 -subj '/CN=Test Signature Database key/' -out db.crt + openssl x509 -outform DER -in db.crt -out db.cer + else + echo "Reusing Secure Boot keys in ${secureboot}" + fi + cd - +fi + +runv podman build -t $output_image --build-arg=COMPOSEFS_FSVERITY=${cfs_digest} --build-arg=base=${input_image} \ + --secret=id=key,src=${secureboot}/db.key \ + --secret=id=cert,src=${secureboot}/db.crt -f Dockerfile.cfsuki .