diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs new file mode 100644 index 000000000..0108304fe --- /dev/null +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -0,0 +1,797 @@ +use std::fs::create_dir_all; +use std::io::Write; +use std::process::Command; +use std::{ffi::OsStr, path::PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use bootc_blockdev::find_parent_devices; +use bootc_mount::inspect_filesystem; +use bootc_utils::CommandRunExt; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use clap::ValueEnum; +use composefs::fs::read_file; +use composefs::tree::FileSystem; +use composefs_boot::BootOps; +use fn_error_context::context; +use ostree_ext::composefs::{ + fsverity::{FsVerityHashValue, Sha256HashValue}, + repository::Repository as ComposefsRepository, +}; +use ostree_ext::composefs_boot::bootloader::UsrLibModulesVmlinuz; +use ostree_ext::composefs_boot::{ + bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, + os_release::OsReleaseInfo, uki, +}; +use ostree_ext::composefs_oci::image::create_filesystem as create_composefs_filesystem; +use rustix::path::Arg; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::bootc_composefs::repo::open_composefs_repo; +use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; +use crate::bootc_composefs::status::get_sorted_uki_boot_entries; +use crate::parsers::bls_config::BLSConfig; +use crate::parsers::grub_menuconfig::MenuEntry; +use crate::spec::ImageReference; +use crate::task::Task; +use crate::{ + composefs_consts::{ + BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, + STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, USER_CFG, USER_CFG_STAGED, + }, + install::{DPS_UUID, ESP_GUID, RW_KARG}, + spec::{Bootloader, Host}, +}; + +use crate::install::{RootSetup, State}; + +/// Contains the EFP's filesystem UUID. Used by grub +pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; +/// The EFI Linux directory +const EFI_LINUX: &str = "EFI/Linux"; + +pub(crate) enum BootSetupType<'a> { + /// For initial setup, i.e. install to-disk + Setup((&'a RootSetup, &'a State, &'a FileSystem)), + /// For `bootc upgrade` + Upgrade((&'a FileSystem, &'a Host)), +} + +#[derive( + ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema, +)] +pub enum BootType { + #[default] + Bls, + Uki, +} + +impl ::std::fmt::Display for BootType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + BootType::Bls => "bls", + BootType::Uki => "uki", + }; + + write!(f, "{}", s) + } +} + +impl TryFrom<&str> for BootType { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + match value { + "bls" => Ok(Self::Bls), + "uki" => Ok(Self::Uki), + unrecognized => Err(anyhow::anyhow!( + "Unrecognized boot option: '{unrecognized}'" + )), + } + } +} + +impl From<&ComposefsBootEntry> for BootType { + fn from(entry: &ComposefsBootEntry) -> Self { + match entry { + ComposefsBootEntry::Type1(..) => Self::Bls, + ComposefsBootEntry::Type2(..) => Self::Uki, + ComposefsBootEntry::UsrLibModulesUki(..) => Self::Uki, + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + } + } +} + +/// Returns the beginning of the grub2/user.cfg file +/// where we source a file containing the ESPs filesystem UUID +pub(crate) fn get_efi_uuid_source() -> String { + format!( + r#" +if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then + source ${{config_directory}}/{EFI_UUID_FILE} +fi +"# + ) +} + +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}"))?; + + Ok((esp.node, esp.uuid)) +} + +/// Compute SHA256Sum of VMlinuz + Initrd +/// +/// # Arguments +/// * entry - BootEntry containing VMlinuz and Initrd +/// * repo - The composefs repository +#[context("Computing boot digest")] +fn compute_boot_digest( + entry: &UsrLibModulesVmlinuz, + repo: &ComposefsRepository, +) -> Result { + let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + let initramfs = read_file(initramfs, &repo).context("Reading intird")?; + + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) + .context("Creating hasher")?; + + hasher.update(&vmlinuz).context("hashing vmlinuz")?; + hasher.update(&initramfs).context("hashing initrd")?; + + let digest: &[u8] = &hasher.finish().context("Finishing digest")?; + + return Ok(hex::encode(digest)); +} + +/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum +/// +/// # Returns +/// Returns the verity of the deployment that has a boot digest same as the one passed in +#[context("Checking boot entry duplicates")] +fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { + let deployments = + cap_std::fs::Dir::open_ambient_dir(STATE_DIR_ABS, cap_std::ambient_authority()); + + let deployments = match deployments { + Ok(d) => d, + // The first ever deployment + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => anyhow::bail!(e), + }; + + let mut symlink_to: Option = None; + + for depl in deployments.entries()? { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.as_str()?; + + let config = depl + .open_dir() + .with_context(|| format!("Opening {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .context("Reading origin file")?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + match ini.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST) { + Some(hash) => { + if hash == digest { + symlink_to = Some(depl_file_name.to_string()); + break; + } + } + + // No SHASum recorded in origin file + // `symlink_to` is already none, but being explicit here + None => symlink_to = None, + }; + } + + Ok(symlink_to) +} + +#[context("Writing BLS entries to disk")] +fn write_bls_boot_entries_to_disk( + boot_dir: &Utf8PathBuf, + deployment_id: &Sha256HashValue, + entry: &UsrLibModulesVmlinuz, + repo: &ComposefsRepository, +) -> Result<()> { + let id_hex = deployment_id.to_hex(); + + // Write the initrd and vmlinuz at /boot// + let path = boot_dir.join(&id_hex); + create_dir_all(&path)?; + + let entries_dir = cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + + entries_dir + .atomic_write( + "vmlinuz", + read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?, + ) + .context("Writing vmlinuz to path")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + entries_dir + .atomic_write( + "initrd", + read_file(initramfs, &repo).context("Reading initrd")?, + ) + .context("Writing initrd to path")?; + + // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd + let owned_fd = entries_dir + .reopen_as_ownedfd() + .context("Reopen as owned fd")?; + + rustix::fs::fsync(owned_fd).context("fsync")?; + + Ok(()) +} + +struct BLSEntryPath<'a> { + /// Where to write vmlinuz/initrd + entries_path: Utf8PathBuf, + /// The absolute path, with reference to the partition's root, where the vmlinuz/initrd are written to + /// We need this as when installing, the mounted path will not + abs_entries_path: &'a str, + /// Where to write the .conf files + config_path: Utf8PathBuf, + /// If we mounted EFI, the target path + mount_path: Option, +} + +/// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk +/// +/// # Returns +/// Returns the SHA256Sum of VMLinuz + Initrd combo. Error if any +#[context("Setting up BLS boot")] +pub(crate) fn setup_composefs_bls_boot( + setup_type: BootSetupType, + // TODO: Make this generic + repo: ComposefsRepository, + id: &Sha256HashValue, + entry: ComposefsBootEntry, +) -> Result { + let id_hex = id.to_hex(); + + let (root_path, esp_device, cmdline_refs, fs, bootloader) = match setup_type { + BootSetupType::Setup((root_setup, state, fs)) => { + // root_setup.kargs has [root=UUID=, "rw"] + let mut cmdline_options = String::from(root_setup.kargs.join(" ")); + + match &state.composefs_options { + Some(opt) if opt.insecure => { + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}=?{id_hex}")); + } + None | Some(..) => { + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}={id_hex}")); + } + }; + + // 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"))?; + + ( + 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()), + ) + } + + BootSetupType::Upgrade((fs, host)) => { + let sysroot = Utf8PathBuf::from("/sysroot"); + + let fsinfo = inspect_filesystem(&sysroot)?; + let parent_devices = find_parent_devices(&fsinfo.source)?; + + let Some(parent) = parent_devices.into_iter().next() else { + anyhow::bail!("Could not find parent device for mountpoint /sysroot"); + }; + + let bootloader = host.require_composefs_booted()?.bootloader.clone(); + + ( + Utf8PathBuf::from("/sysroot"), + get_esp_partition(&parent)?.0, + [ + format!("root=UUID={DPS_UUID}"), + RW_KARG.to_string(), + format!("{COMPOSEFS_CMDLINE}={id_hex}"), + ] + .join(" "), + fs, + bootloader, + ) + } + }; + + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); + + let (entry_paths, _tmpdir_guard) = match bootloader { + Bootloader::Grub => ( + BLSEntryPath { + entries_path: root_path.join("boot"), + config_path: root_path.join("boot"), + abs_entries_path: "boot", + mount_path: None, + }, + None, + ), + + Bootloader::Systemd => { + let temp_efi_dir = tempfile::tempdir().map_err(|e| { + anyhow::anyhow!("Failed to create temporary directory for EFI mount: {e}") + })?; + + let mounted_efi = Utf8PathBuf::from_path_buf(temp_efi_dir.path().to_path_buf()) + .map_err(|_| anyhow::anyhow!("EFI dir is not valid UTF-8"))?; + + Command::new("mount") + .args([&PathBuf::from(&esp_device), mounted_efi.as_std_path()]) + .log_debug() + .run_inherited_with_cmd_context() + .context("Mounting EFI")?; + + let efi_linux_dir = mounted_efi.join(EFI_LINUX); + + ( + BLSEntryPath { + entries_path: efi_linux_dir, + config_path: mounted_efi.clone(), + abs_entries_path: EFI_LINUX, + mount_path: Some(mounted_efi), + }, + Some(temp_efi_dir), + ) + } + }; + + let (bls_config, boot_digest) = match &entry { + ComposefsBootEntry::Type1(..) => unimplemented!(), + ComposefsBootEntry::Type2(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), + + ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { + let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) + .context("Computing boot digest")?; + + // Every update should have its own /usr/lib/os-release + let (dir, fname) = fs + .root + .split(OsStr::new("/usr/lib/os-release")) + .context("Getting /usr/lib/os-release")?; + + let os_release = dir + .get_file_opt(fname) + .context("Getting /usr/lib/os-release")?; + + let version = os_release.and_then(|os_rel_file| { + let file_contents = match read_file(os_rel_file, &repo) { + Ok(c) => c, + Err(e) => { + tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); + return None; + } + }; + + let file_contents = match std::str::from_utf8(&file_contents) { + Ok(c) => c, + Err(..) => { + tracing::warn!("/usr/lib/os-release did not have valid UTF-8"); + return None; + } + }; + + OsReleaseInfo::parse(file_contents).get_version() + }); + + let default_sort_key = "1"; + + let mut bls_config = BLSConfig::default(); + + bls_config + .with_title(id_hex.clone()) + .with_sort_key(default_sort_key.into()) + .with_version(version.unwrap_or(default_sort_key.into())) + .with_linux(format!( + "/{}/{id_hex}/vmlinuz", + entry_paths.abs_entries_path + )) + .with_initrd(vec![format!( + "/{}/{id_hex}/initrd", + entry_paths.abs_entries_path + )]) + .with_options(cmdline_refs); + + if let Some(symlink_to) = find_vmlinuz_initrd_duplicates(&boot_digest)? { + bls_config.linux = + format!("/{}/{symlink_to}/vmlinuz", entry_paths.abs_entries_path); + + bls_config.initrd = vec![format!( + "/{}/{symlink_to}/initrd", + entry_paths.abs_entries_path + )]; + } else { + write_bls_boot_entries_to_disk( + &entry_paths.entries_path, + id, + usr_lib_modules_vmlinuz, + &repo, + )?; + } + + (bls_config, boot_digest) + } + }; + + let (config_path, booted_bls) = if is_upgrade { + let mut booted_bls = get_booted_bls()?; + booted_bls.sort_key = Some("0".into()); // entries are sorted by their filename in reverse order + + // This will be atomically renamed to 'loader/entries' on shutdown/reboot + ( + entry_paths + .config_path + .join("loader") + .join(STAGED_BOOT_LOADER_ENTRIES), + Some(booted_bls), + ) + } else { + ( + entry_paths + .config_path + .join("loader") + .join(BOOT_LOADER_ENTRIES), + None, + ) + }; + + create_dir_all(&config_path).with_context(|| format!("Creating {:?}", config_path))?; + + // Scope to allow for proper unmounting + { + let loader_entries_dir = + cap_std::fs::Dir::open_ambient_dir(&config_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {config_path:?}"))?; + + loader_entries_dir.atomic_write( + // SAFETY: We set sort_key above + format!( + "bootc-composefs-{}.conf", + bls_config.sort_key.as_ref().unwrap() + ), + bls_config.to_string().as_bytes(), + )?; + + if let Some(booted_bls) = booted_bls { + loader_entries_dir.atomic_write( + // SAFETY: We set sort_key above + format!( + "bootc-composefs-{}.conf", + booted_bls.sort_key.as_ref().unwrap() + ), + booted_bls.to_string().as_bytes(), + )?; + } + + let owned_loader_entries_fd = loader_entries_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?; + + rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?; + } + + if let Some(mounted_efi) = entry_paths.mount_path { + Command::new("umount") + .arg(mounted_efi) + .log_debug() + .run_inherited_with_cmd_context() + .context("Unmounting EFI")?; + } + + Ok(boot_digest) +} + +#[context("Setting up UKI boot")] +pub(crate) fn setup_composefs_uki_boot( + setup_type: BootSetupType, + // TODO: Make this generic + repo: ComposefsRepository, + id: &Sha256HashValue, + entry: ComposefsBootEntry, +) -> Result<()> { + let (root_path, esp_device, is_insecure_from_opts) = match setup_type { + BootSetupType::Setup((root_setup, state, ..)) => { + if let Some(v) = &state.config_opts.karg { + if v.len() > 0 { + tracing::warn!("kargs passed for UKI will be ignored"); + } + } + + 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"))?; + + ( + root_setup.physical_root_path.clone(), + esp_part.node.clone(), + state.composefs_options.as_ref().map(|x| x.insecure), + ) + } + + BootSetupType::Upgrade(..) => { + let sysroot = Utf8PathBuf::from("/sysroot"); + + let fsinfo = inspect_filesystem(&sysroot)?; + let parent_devices = find_parent_devices(&fsinfo.source)?; + + let Some(parent) = parent_devices.into_iter().next() else { + anyhow::bail!("Could not find parent device for mountpoint /sysroot"); + }; + + (sysroot, get_esp_partition(&parent)?.0, None) + } + }; + + let temp_efi_dir = tempfile::tempdir() + .map_err(|e| anyhow::anyhow!("Failed to create temporary directory for EFI mount: {e}"))?; + let mounted_efi = temp_efi_dir.path().to_path_buf(); + + Task::new("Mounting ESP", "mount") + .args([&PathBuf::from(&esp_device), &mounted_efi.clone()]) + .run()?; + + let boot_label = match entry { + ComposefsBootEntry::Type1(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => unimplemented!(), + + ComposefsBootEntry::Type2(type2_entry) => { + let uki = read_file(&type2_entry.file, &repo).context("Reading UKI")?; + let cmdline = uki::get_cmdline(&uki).context("Getting UKI cmdline")?; + let (composefs_cmdline, insecure) = get_cmdline_composefs::(cmdline)?; + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + if let Some(is_insecure_from_opts) = is_insecure_from_opts { + match is_insecure_from_opts { + true => { + if !insecure { + tracing::warn!( + "--insecure passed as option but UKI cmdline does not support it" + ) + } + } + + false => { + if insecure { + tracing::warn!("UKI cmdline has composefs set as insecure") + } + } + } + } + + let boot_label = uki::get_boot_label(&uki).context("Getting UKI boot label")?; + + if composefs_cmdline != *id { + anyhow::bail!( + "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {id:?})" + ); + } + + // Write the UKI to ESP + let efi_linux_path = mounted_efi.join(EFI_LINUX); + create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?; + + let efi_linux = + cap_std::fs::Dir::open_ambient_dir(&efi_linux_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {efi_linux_path:?}"))?; + + efi_linux + .atomic_write(format!("{}.efi", id.to_hex()), uki) + .context("Writing UKI")?; + + rustix::fs::fsync( + efi_linux + .reopen_as_ownedfd() + .context("Reopening as owned fd")?, + ) + .context("fsync")?; + + boot_label + } + }; + + Command::new("umount") + .arg(&mounted_efi) + .log_debug() + .run_inherited_with_cmd_context() + .context("Unmounting ESP")?; + + let boot_dir = root_path.join("boot"); + create_dir_all(&boot_dir).context("Failed to create boot dir")?; + + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); + + let efi_uuid_source = get_efi_uuid_source(); + + let user_cfg_name = if is_upgrade { + USER_CFG_STAGED + } else { + USER_CFG + }; + + let grub_dir = + cap_std::fs::Dir::open_ambient_dir(boot_dir.join("grub2"), cap_std::ambient_authority()) + .context("opening boot/grub2")?; + + // Iterate over all available deployments, and generate a menuentry for each + // + // TODO: We might find a staged deployment here + if is_upgrade { + let mut buffer = vec![]; + + // Shouldn't really fail so no context here + buffer.write_all(efi_uuid_source.as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + let mut str_buf = String::new(); + let boot_dir = cap_std::fs::Dir::open_ambient_dir(boot_dir, cap_std::ambient_authority()) + .context("Opening boot dir")?; + let entries = get_sorted_uki_boot_entries(&boot_dir, &mut str_buf)?; + + // Write out only the currently booted entry, which should be the very first one + // Even if we have booted into the second menuentry "boot entry", the default will be the + // first one + buffer.write_all(entries[0].to_string().as_bytes())?; + + grub_dir + .atomic_write(user_cfg_name, buffer) + .with_context(|| format!("Writing to {user_cfg_name}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; + + return Ok(()); + } + + // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there + // This will be sourced by grub2/user.cfg to be used for `--fs-uuid` + let esp_uuid = Task::new("blkid for ESP UUID", "blkid") + .args(["-s", "UUID", "-o", "value", &esp_device]) + .read()?; + + grub_dir.atomic_write( + EFI_UUID_FILE, + format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(), + )?; + + // Write to grub2/user.cfg + let mut buffer = vec![]; + + // Shouldn't really fail so no context here + buffer.write_all(efi_uuid_source.as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + grub_dir + .atomic_write(user_cfg_name, buffer) + .with_context(|| format!("Writing to {user_cfg_name}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; + + Ok(()) +} + +#[context("Setting up composefs boot")] +pub(crate) fn setup_composefs_boot( + root_setup: &RootSetup, + state: &State, + image_id: &str, +) -> Result<()> { + let boot_uuid = root_setup + .get_boot_uuid()? + .or(root_setup.rootfs_uuid.as_deref()) + .ok_or_else(|| anyhow!("No uuid for boot/root"))?; + + 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 { + crate::bootloader::install_via_bootupd( + &root_setup.device_info, + &root_setup.physical_root_path, + &state.config_opts, + None, + )?; + } + + let repo = open_composefs_repo(&root_setup.physical_root)?; + + let mut fs = create_composefs_filesystem(&repo, image_id, None)?; + + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(&entry); + let mut boot_digest: Option = None; + + match boot_type { + BootType::Bls => { + let digest = setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &fs)), + repo, + &id, + entry, + )?; + + boot_digest = Some(digest); + } + BootType::Uki => setup_composefs_uki_boot( + BootSetupType::Setup((&root_setup, &state, &fs)), + repo, + &id, + entry, + )?, + }; + + write_composefs_state( + &root_setup.physical_root_path, + id, + &ImageReference { + image: state.source.imageref.name.clone(), + transport: state.source.imageref.transport.to_string(), + signature: None, + }, + false, + boot_type, + boot_digest, + )?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 33e14d22a..fee03cee9 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1 +1,7 @@ +pub(crate) mod boot; +pub(crate) mod repo; +pub(crate) mod rollback; pub(crate) mod state; +pub(crate) mod status; +pub(crate) mod switch; +pub(crate) mod update; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs new file mode 100644 index 000000000..1538b72bd --- /dev/null +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -0,0 +1,88 @@ +use fn_error_context::context; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use ostree_ext::composefs::{ + fsverity::{FsVerityHashValue, Sha256HashValue}, + repository::Repository as ComposefsRepository, + tree::FileSystem, + util::Sha256Digest, +}; +use ostree_ext::composefs_boot::{bootloader::BootEntry as ComposefsBootEntry, BootOps}; +use ostree_ext::composefs_oci::{ + image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, +}; + +use ostree_ext::container::ImageReference as OstreeExtImgRef; + +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; + +use crate::install::{RootSetup, State}; + +pub(crate) fn open_composefs_repo( + rootfs_dir: &Dir, +) -> Result> { + ComposefsRepository::open_path(rootfs_dir, "composefs") + .context("Failed to open composefs repository") +} + +pub(crate) async fn initialize_composefs_repository( + state: &State, + root_setup: &RootSetup, +) -> Result<(Sha256Digest, impl FsVerityHashValue)> { + let rootfs_dir = &root_setup.physical_root; + + rootfs_dir + .create_dir_all("composefs") + .context("Creating dir composefs")?; + + let repo = open_composefs_repo(rootfs_dir)?; + + let OstreeExtImgRef { + name: image_name, + transport, + } = &state.source.imageref; + + // transport's display is already of type ":" + composefs_oci_pull( + &Arc::new(repo), + &format!("{transport}{image_name}"), + None, + None, + ) + .await +} + +/// Pulls the `image` from `transport` into a composefs repository at /sysroot +/// Checks for boot entries in the image and returns them +#[context("Pulling composefs repository")] +pub(crate) async fn pull_composefs_repo( + transport: &String, + image: &String, +) -> Result<( + ComposefsRepository, + Vec>, + Sha256HashValue, + FileSystem, +)> { + let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; + + let repo = open_composefs_repo(&rootfs_dir).context("Opening compoesfs repo")?; + + let (id, verity) = + composefs_oci_pull(&Arc::new(repo), &format!("{transport}:{image}"), None, None) + .await + .context("Pulling composefs repo")?; + + tracing::info!("id: {}, verity: {}", hex::encode(id), verity.to_hex()); + + let repo = open_composefs_repo(&rootfs_dir)?; + let mut fs = create_composefs_filesystem(&repo, &hex::encode(id), None) + .context("Failed to create composefs filesystem")?; + + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + + Ok((repo, entries, id, fs)) +} diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs new file mode 100644 index 000000000..aa948e835 --- /dev/null +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -0,0 +1,195 @@ +use std::path::PathBuf; +use std::{fmt::Write, fs::create_dir_all}; + +use anyhow::{anyhow, Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use fn_error_context::context; +use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; + +use crate::bootc_composefs::boot::BootType; +use crate::bootc_composefs::status::{composefs_deployment_status, get_sorted_bls_boot_entries}; +use crate::{ + bootc_composefs::{boot::get_efi_uuid_source, status::get_sorted_uki_boot_entries}, + composefs_consts::{ + BOOT_LOADER_ENTRIES, ROLLBACK_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_ROLLBACK, + }, + spec::BootOrder, +}; + +#[context("Rolling back UKI")] +pub(crate) fn rollback_composefs_uki() -> Result<()> { + let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); + + let mut str = String::new(); + let boot_dir = + cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) + .context("Opening boot dir")?; + let mut menuentries = + get_sorted_uki_boot_entries(&boot_dir, &mut str).context("Getting UKI boot entries")?; + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(menuentries.len() == 2); + + let (first, second) = menuentries.split_at_mut(1); + std::mem::swap(&mut first[0], &mut second[0]); + + let mut buffer = get_efi_uuid_source(); + + for entry in menuentries { + write!(buffer, "{entry}")?; + } + + let entries_dir = + cap_std::fs::Dir::open_ambient_dir(&user_cfg_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {user_cfg_path:?}"))?; + + entries_dir + .atomic_write(USER_CFG_ROLLBACK, buffer) + .with_context(|| format!("Writing to {USER_CFG_ROLLBACK}"))?; + + tracing::debug!("Atomically exchanging for {USER_CFG_ROLLBACK} and {USER_CFG}"); + renameat_with( + &entries_dir, + USER_CFG_ROLLBACK, + &entries_dir, + USER_CFG, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {USER_CFG_ROLLBACK}"); + rustix::fs::unlinkat(&entries_dir, USER_CFG_ROLLBACK, AtFlags::empty()).context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + fsync( + entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening {user_cfg_path:?} as owned fd"))?, + ) + .with_context(|| format!("fsync {user_cfg_path:?}"))?; + + Ok(()) +} + +#[context("Rolling back BLS")] +pub(crate) fn rollback_composefs_bls() -> Result<()> { + let boot_dir = + cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) + .context("Opening boot dir")?; + + // Sort in descending order as that's the order they're shown on the boot screen + // After this: + // all_configs[0] -> booted depl + // all_configs[1] -> rollback depl + let mut all_configs = get_sorted_bls_boot_entries(&boot_dir, false)?; + + // Update the indicies so that they're swapped + for (idx, cfg) in all_configs.iter_mut().enumerate() { + cfg.sort_key = Some(idx.to_string()); + } + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(all_configs.len() == 2); + + // Write these + let dir_path = PathBuf::from(format!( + "/sysroot/boot/loader/{ROLLBACK_BOOT_LOADER_ENTRIES}", + )); + create_dir_all(&dir_path).with_context(|| format!("Failed to create dir: {dir_path:?}"))?; + + let rollback_entries_dir = + cap_std::fs::Dir::open_ambient_dir(&dir_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {dir_path:?}"))?; + + // Write the BLS configs in there + for cfg in all_configs { + // SAFETY: We set sort_key above + let file_name = format!("bootc-composefs-{}.conf", cfg.sort_key.as_ref().unwrap()); + + rollback_entries_dir + .atomic_write(&file_name, cfg.to_string()) + .with_context(|| format!("Writing to {file_name}"))?; + } + + // Should we sync after every write? + fsync( + rollback_entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening {dir_path:?} as owned fd"))?, + ) + .with_context(|| format!("fsync {dir_path:?}"))?; + + // Atomically exchange "entries" <-> "entries.rollback" + let dir = Dir::open_ambient_dir("/sysroot/boot/loader", cap_std::ambient_authority()) + .context("Opening loader dir")?; + + tracing::debug!( + "Atomically exchanging for {ROLLBACK_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}" + ); + renameat_with( + &dir, + ROLLBACK_BOOT_LOADER_ENTRIES, + &dir, + BOOT_LOADER_ENTRIES, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {ROLLBACK_BOOT_LOADER_ENTRIES}"); + rustix::fs::unlinkat(&dir, ROLLBACK_BOOT_LOADER_ENTRIES, AtFlags::empty()) + .context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + fsync( + dir.reopen_as_ownedfd() + .with_context(|| format!("Reopening /sysroot/boot/loader as owned fd"))?, + ) + .context("fsync")?; + + Ok(()) +} + +#[context("Rolling back composefs")] +pub(crate) async fn composefs_rollback() -> Result<()> { + let host = composefs_deployment_status().await?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.boot_order = new_spec.boot_order.swap(); + new_spec + }; + + // Just to be sure + host.spec.verify_transition(&new_spec)?; + + let reverting = new_spec.boot_order == BootOrder::Default; + if reverting { + println!("notice: Reverting queued rollback state"); + } + + let rollback_status = host + .status + .rollback + .ok_or_else(|| anyhow!("No rollback available"))?; + + // TODO: Handle staged deployment + // Ostree will drop any staged deployment on rollback but will keep it if it is the first item + // in the new deployment list + let Some(rollback_composefs_entry) = &rollback_status.composefs else { + anyhow::bail!("Rollback deployment not a composefs deployment") + }; + + match rollback_composefs_entry.boot_type { + BootType::Bls => rollback_composefs_bls(), + BootType::Uki => rollback_composefs_uki(), + }?; + + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 23e0aa95e..b1bb8db15 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -1,16 +1,60 @@ -use std::process::Command; +use std::os::unix::fs::symlink; +use std::{fs::create_dir_all, process::Command}; use anyhow::{Context, Result}; +use bootc_kernel_cmdline::Cmdline; use bootc_utils::CommandRunExt; use camino::Utf8PathBuf; +use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; +use composefs::fsverity::{FsVerityHashValue, Sha256HashValue}; use fn_error_context::context; +use ostree_ext::container::deploy::ORIGIN_CONTAINER; use rustix::{ fs::{open, Mode, OFlags, CWD}, mount::{unmount, UnmountFlags}, path::Arg, }; +use crate::bootc_composefs::boot::BootType; +use crate::{ + composefs_consts::{ + COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, SHARED_VAR_PATH, + STATE_DIR_RELATIVE, + }, + parsers::bls_config::{parse_bls_config, BLSConfig}, + spec::ImageReference, + utils::path_relative_to, +}; + +pub(crate) fn get_booted_bls() -> Result { + let cmdline = Cmdline::from_proc()?; + let booted = cmdline + .find_str(COMPOSEFS_CMDLINE) + .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; + + for entry in std::fs::read_dir("/sysroot/boot/loader/entries")? { + let entry = entry?; + + if !entry.file_name().as_str()?.ends_with(".conf") { + continue; + } + + let bls = parse_bls_config(&std::fs::read_to_string(&entry.path())?)?; + + let Some(opts) = &bls.options else { + anyhow::bail!("options not found in bls config") + }; + + if opts.contains(booted.as_ref()) { + return Ok(bls); + } + } + + Err(anyhow::anyhow!("Booted BLS not found")) +} + /// Mounts an EROFS image and copies the pristine /etc to the deployment's /etc #[context("Copying etc")] pub(crate) fn copy_etc_to_state( @@ -45,3 +89,81 @@ pub(crate) fn copy_etc_to_state( cp_ret } + +/// Creates and populates /sysroot/state/deploy/image_id +#[context("Writing composefs state")] +pub(crate) fn write_composefs_state( + root_path: &Utf8PathBuf, + deployment_id: Sha256HashValue, + imgref: &ImageReference, + staged: bool, + boot_type: BootType, + boot_digest: Option, +) -> Result<()> { + let state_path = root_path.join(format!("{STATE_DIR_RELATIVE}/{}", deployment_id.to_hex())); + + create_dir_all(state_path.join("etc"))?; + + copy_etc_to_state(&root_path, &deployment_id.to_hex(), &state_path)?; + + let actual_var_path = root_path.join(SHARED_VAR_PATH); + create_dir_all(&actual_var_path)?; + + symlink( + path_relative_to(state_path.as_std_path(), actual_var_path.as_std_path()) + .context("Getting var symlink path")?, + state_path.join("var"), + ) + .context("Failed to create symlink for /var")?; + + let ImageReference { + image: image_name, + transport, + .. + } = &imgref; + + let mut config = tini::Ini::new().section("origin").item( + ORIGIN_CONTAINER, + format!("ostree-unverified-image:{transport}{image_name}"), + ); + + config = config + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_TYPE, boot_type); + + if let Some(boot_digest) = boot_digest { + config = config + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest); + } + + let state_dir = cap_std::fs::Dir::open_ambient_dir(&state_path, cap_std::ambient_authority()) + .context("Opening state dir")?; + + state_dir + .atomic_write( + format!("{}.origin", deployment_id.to_hex()), + config.to_string().as_bytes(), + ) + .context("Failed to write to .origin file")?; + + if staged { + std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) + .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + + let staged_depl_dir = cap_std::fs::Dir::open_ambient_dir( + COMPOSEFS_TRANSIENT_STATE_DIR, + cap_std::ambient_authority(), + ) + .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + + staged_depl_dir + .atomic_write( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + deployment_id.to_hex().as_bytes(), + ) + .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; + } + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs new file mode 100644 index 000000000..1d38b3749 --- /dev/null +++ b/crates/lib/src/bootc_composefs/status.rs @@ -0,0 +1,505 @@ +use std::{io::Read, sync::OnceLock}; + +use anyhow::{Context, Result}; +use bootc_kernel_cmdline::Cmdline; +use fn_error_context::context; + +use crate::{ + bootc_composefs::boot::BootType, + composefs_consts::{BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, USER_CFG}, + parsers::{ + bls_config::{parse_bls_config, BLSConfig}, + grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}, + }, + spec::{BootEntry, BootOrder, Host, HostSpec, ImageReference, ImageStatus}, +}; + +use std::str::FromStr; + +use bootc_utils::try_deserialize_timestamp; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::Dir; +use ostree_container::OstreeImageReference; +use ostree_ext::container::deploy::ORIGIN_CONTAINER; +use ostree_ext::container::{self as ostree_container}; +use ostree_ext::containers_image_proxy; +use ostree_ext::oci_spec; + +use ostree_ext::oci_spec::image::ImageManifest; +use tokio::io::AsyncReadExt; + +use crate::composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, + ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, +}; +use crate::install::EFIVARFS; +use crate::spec::Bootloader; + +/// A parsed composefs command line +pub(crate) struct ComposefsCmdline { + #[allow(dead_code)] + pub insecure: bool, + pub digest: Box, +} + +impl ComposefsCmdline { + pub(crate) fn new(s: &str) -> Self { + let (insecure, digest_str) = s + .strip_prefix('?') + .map(|v| (true, v)) + .unwrap_or_else(|| (false, s)); + ComposefsCmdline { + insecure, + digest: digest_str.into(), + } + } +} + +impl std::fmt::Display for ComposefsCmdline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let insecure = if self.insecure { "?" } else { "" }; + write!(f, "{}={}{}", COMPOSEFS_CMDLINE, insecure, self.digest) + } +} + +/// Detect if we have composefs= in /proc/cmdline +pub(crate) fn composefs_booted() -> Result> { + static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); + if let Some(v) = CACHED_DIGEST_VALUE.get() { + return Ok(v.as_ref()); + } + let cmdline = Cmdline::from_proc()?; + let Some(kv) = cmdline.find_str(COMPOSEFS_CMDLINE) else { + return Ok(None); + }; + let Some(v) = kv.value else { return Ok(None) }; + let v = ComposefsCmdline::new(v); + let r = CACHED_DIGEST_VALUE.get_or_init(|| Some(v)); + Ok(r.as_ref()) +} + +// Need str to store lifetime +pub(crate) fn get_sorted_uki_boot_entries<'a>( + boot_dir: &Dir, + str: &'a mut String, +) -> Result>> { + let mut file = boot_dir + .open(format!("grub2/{USER_CFG}")) + .with_context(|| format!("Opening {USER_CFG}"))?; + file.read_to_string(str)?; + parse_grub_menuentry_file(str) +} + +#[context("Getting sorted BLS entries")] +pub(crate) fn get_sorted_bls_boot_entries( + boot_dir: &Dir, + ascending: bool, +) -> Result> { + let mut all_configs = vec![]; + + for entry in boot_dir.read_dir(format!("loader/{BOOT_LOADER_ENTRIES}"))? { + let entry = entry?; + + let file_name = entry.file_name(); + + let file_name = file_name + .to_str() + .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?; + + if !file_name.ends_with(".conf") { + continue; + } + + let mut file = entry + .open() + .with_context(|| format!("Failed to open {:?}", file_name))?; + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .with_context(|| format!("Failed to read {:?}", file_name))?; + + let config = parse_bls_config(&contents).context("Parsing bls config")?; + + all_configs.push(config); + } + + all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); + + return Ok(all_configs); +} + +/// imgref = transport:image_name +#[context("Getting container info")] +async fn get_container_manifest_and_config( + imgref: &String, +) -> Result<(ImageManifest, oci_spec::image::ImageConfiguration)> { + let config = containers_image_proxy::ImageProxyConfig::default(); + let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?; + + let img = proxy.open_image(&imgref).await.context("Opening image")?; + + let (_, manifest) = proxy.fetch_manifest(&img).await?; + let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?; + + let mut buf = Vec::with_capacity(manifest.config().size() as usize); + buf.resize(manifest.config().size() as usize, 0); + reader.read_exact(&mut buf).await?; + driver.await?; + + let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?; + + Ok((manifest, config)) +} + +#[context("Getting bootloader")] +fn get_bootloader() -> Result { + let efivarfs = match Dir::open_ambient_dir(EFIVARFS, ambient_authority()) { + Ok(dir) => dir, + // Most likely using BIOS + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::Grub), + Err(e) => Err(e).context(format!("Opening {EFIVARFS}"))?, + }; + + const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; + + match efivarfs.read_to_string(EFI_LOADER_INFO) { + Ok(loader) => { + if loader.to_lowercase().contains("systemd-boot") { + return Ok(Bootloader::Systemd); + } + + return Ok(Bootloader::Grub); + } + + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::Grub), + + Err(e) => Err(e).context(format!("Opening {EFI_LOADER_INFO}"))?, + } +} + +#[context("Getting composefs deployment metadata")] +async fn boot_entry_from_composefs_deployment( + origin: tini::Ini, + verity: String, +) -> Result { + let image = match origin.get::("origin", ORIGIN_CONTAINER) { + Some(img_name_from_config) => { + let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; + let imgref = ostree_img_ref.imgref.to_string(); + let img_ref = ImageReference::from(ostree_img_ref); + + // The image might've been removed, so don't error if we can't get the image manifest + let (image_digest, version, architecture, created_at) = + match get_container_manifest_and_config(&imgref).await { + Ok((manifest, config)) => { + let digest = manifest.config().digest().to_string(); + let arch = config.architecture().to_string(); + let created = config.created().clone(); + let version = manifest + .annotations() + .as_ref() + .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned()); + + (digest, version, arch, created) + } + + Err(e) => { + tracing::debug!("Failed to open image {img_ref}, because {e:?}"); + ("".into(), None, "".into(), None) + } + }; + + let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x)); + + let image_status = ImageStatus { + image: img_ref, + version, + timestamp, + image_digest, + architecture, + }; + + Some(image_status) + } + + // Wasn't booted using a container image. Do nothing + None => None, + }; + + let boot_type = match origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) { + Some(s) => BootType::try_from(s.as_str())?, + None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"), + }; + + let e = BootEntry { + image, + cached_update: None, + incompatible: false, + pinned: false, + store: None, + ostree: None, + composefs: Some(crate::spec::BootEntryComposefs { + verity, + boot_type, + bootloader: get_bootloader()?, + }), + soft_reboot_capable: false, + }; + + return Ok(e); +} + +#[context("Getting composefs deployment status")] +pub(crate) async fn composefs_deployment_status() -> Result { + let composefs_state = composefs_booted()? + .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; + let composefs_digest = &composefs_state.digest; + + let sysroot = + Dir::open_ambient_dir("/sysroot", ambient_authority()).context("Opening sysroot")?; + let deployments = sysroot + .read_dir(STATE_DIR_RELATIVE) + .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?; + + let host_spec = HostSpec { + image: None, + boot_order: BootOrder::Default, + }; + + let mut host = Host::new(host_spec); + + let staged_deployment_id = match std::fs::File::open(format!( + "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" + )) { + Ok(mut f) => { + let mut s = String::new(); + f.read_to_string(&mut s)?; + + Ok(Some(s)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + }?; + + // NOTE: This cannot work if we support both BLS and UKI at the same time + let mut boot_type: Option = None; + + for depl in deployments { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.to_string_lossy(); + + // read the origin file + let config = depl + .open_dir() + .with_context(|| format!("Failed to open {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .with_context(|| format!("Reading file {depl_file_name}.origin"))?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + let boot_entry = + boot_entry_from_composefs_deployment(ini, depl_file_name.to_string()).await?; + + // SAFETY: boot_entry.composefs will always be present + let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; + + match boot_type { + Some(current_type) => { + if current_type != boot_type_from_origin { + anyhow::bail!("Conflicting boot types") + } + } + + None => { + boot_type = Some(boot_type_from_origin); + } + }; + + if depl.file_name() == composefs_digest.as_ref() { + host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); + host.status.booted = Some(boot_entry); + continue; + } + + if let Some(staged_deployment_id) = &staged_deployment_id { + if depl_file_name == staged_deployment_id.trim() { + host.status.staged = Some(boot_entry); + continue; + } + } + + host.status.rollback = Some(boot_entry); + } + + // Shouldn't really happen, but for sanity nonetheless + let Some(boot_type) = boot_type else { + anyhow::bail!("Could not determine boot type"); + }; + + let boot_dir = sysroot.open_dir("boot").context("Opening boot dir")?; + + match boot_type { + BootType::Bls => { + host.status.rollback_queued = !get_sorted_bls_boot_entries(&boot_dir, false)? + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .options + .as_ref() + .ok_or(anyhow::anyhow!("options key not found in bls config"))? + .contains(composefs_digest.as_ref()); + } + + BootType::Uki => { + let mut s = String::new(); + + host.status.rollback_queued = !get_sorted_uki_boot_entries(&boot_dir, &mut s)? + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .body + .chainloader + .contains(composefs_digest.as_ref()) + } + }; + + if host.status.rollback_queued { + host.spec.boot_order = BootOrder::Rollback + }; + + Ok(host) +} + +#[cfg(test)] +mod tests { + use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; + + use crate::parsers::grub_menuconfig::MenuentryBody; + + use super::*; + + #[test] + fn test_composefs_parsing() { + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + let v = ComposefsCmdline::new(DIGEST); + assert!(!v.insecure); + assert_eq!(v.digest.as_ref(), DIGEST); + let v = ComposefsCmdline::new(&format!("?{}", DIGEST)); + assert!(v.insecure); + assert_eq!(v.digest.as_ref(), DIGEST); + } + + #[test] + fn test_sorted_bls_boot_entries() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let entry1 = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version fedora-42.0 + sort-key 1 + linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 + initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 + "#; + + let entry2 = r#" + title Fedora 41.20250214.2.0 (CoreOS) + version fedora-42.0 + sort-key 2 + linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10 + initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01 + "#; + + tempdir.create_dir_all("loader/entries")?; + tempdir.atomic_write( + "loader/entries/random_file.txt", + "Random file that we won't parse", + )?; + tempdir.atomic_write("loader/entries/entry1.conf", entry1)?; + tempdir.atomic_write("loader/entries/entry2.conf", entry2)?; + + let result = get_sorted_bls_boot_entries(&tempdir, true).unwrap(); + + let mut config1 = BLSConfig::default(); + config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into()); + config1.sort_key = Some("1".into()); + config1.linux = "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(); + config1.initrd = vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()]; + config1.options = Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()); + + let mut config2 = BLSConfig::default(); + config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into()); + config2.sort_key = Some("2".into()); + config2.linux = "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(); + config2.initrd = vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()]; + config2.options = Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into()); + + assert_eq!(result[0].sort_key.as_ref().unwrap(), "1"); + assert_eq!(result[1].sort_key.as_ref().unwrap(), "2"); + + let result = get_sorted_bls_boot_entries(&tempdir, false).unwrap(); + assert_eq!(result[0].sort_key.as_ref().unwrap(), "2"); + assert_eq!(result[1].sort_key.as_ref().unwrap(), "1"); + + Ok(()) + } + + #[test] + fn test_sorted_uki_boot_entries() -> Result<()> { + let user_cfg = r#" + if [ -f ${config_directory}/efiuuid.cfg ]; then + source ${config_directory}/efiuuid.cfg + fi + + menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi + } + + menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi + } + "#; + + let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + bootdir.create_dir_all(format!("grub2"))?; + bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?; + + let mut s = String::new(); + let result = get_sorted_uki_boot_entries(&bootdir, &mut s)?; + + let expected = vec![ + MenuEntry { + title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + MenuEntry { + title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + ]; + + assert_eq!(result, expected); + + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs new file mode 100644 index 000000000..f9f0f3e63 --- /dev/null +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -0,0 +1,73 @@ +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use fn_error_context::context; + +use crate::{ + bootc_composefs::{ + boot::{setup_composefs_bls_boot, setup_composefs_uki_boot, BootSetupType, BootType}, + repo::pull_composefs_repo, + state::write_composefs_state, + status::composefs_deployment_status, + }, + cli::{imgref_for_switch, SwitchOpts}, +}; + +#[context("Composefs Switching")] +pub(crate) async fn switch_composefs(opts: SwitchOpts) -> Result<()> { + let target = imgref_for_switch(&opts)?; + // TODO: Handle in-place + + let host = composefs_deployment_status() + .await + .context("Getting composefs deployment status")?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.image = Some(target.clone()); + new_spec + }; + + if new_spec == host.spec { + println!("Image specification is unchanged."); + return Ok(()); + } + + let Some(target_imgref) = new_spec.image else { + anyhow::bail!("Target image is undefined") + }; + + let (repo, entries, id, fs) = + pull_composefs_repo(&target_imgref.transport, &target_imgref.image).await?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(&entry); + let mut boot_digest = None; + + match boot_type { + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade((&fs, &host)), + repo, + &id, + entry, + )?) + } + BootType::Uki => { + setup_composefs_uki_boot(BootSetupType::Upgrade((&fs, &host)), repo, &id, entry)? + } + }; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + &target_imgref, + true, + boot_type, + boot_digest, + )?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs new file mode 100644 index 000000000..9b9bfa120 --- /dev/null +++ b/crates/lib/src/bootc_composefs/update.rs @@ -0,0 +1,64 @@ +use anyhow::{Context, Result}; +use camino::Utf8PathBuf; +use fn_error_context::context; + +use crate::{ + bootc_composefs::{ + boot::{setup_composefs_bls_boot, setup_composefs_uki_boot, BootSetupType, BootType}, + repo::pull_composefs_repo, + state::write_composefs_state, + status::composefs_deployment_status, + }, + cli::UpgradeOpts, +}; + +#[context("Upgrading composefs")] +pub(crate) async fn upgrade_composefs(_opts: UpgradeOpts) -> Result<()> { + // TODO: IMPORTANT Have all the checks here that `bootc upgrade` has for an ostree booted system + + let host = composefs_deployment_status() + .await + .context("Getting composefs deployment status")?; + + // TODO: IMPORTANT We need to check if any deployment is staged and get the image from that + let imgref = host + .spec + .image + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + + let (repo, entries, id, fs) = pull_composefs_repo(&imgref.transport, &imgref.image).await?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(&entry); + let mut boot_digest = None; + + match boot_type { + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade((&fs, &host)), + repo, + &id, + entry, + )?) + } + + BootType::Uki => { + setup_composefs_uki_boot(BootSetupType::Upgrade((&fs, &host)), repo, &id, entry)? + } + }; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + imgref, + true, + boot_type, + boot_digest, + )?; + + Ok(()) +} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 1a5d25728..6da592789 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -28,16 +28,16 @@ use ostree_ext::sysroot::SysrootLock; use schemars::schema_for; use serde::{Deserialize, Serialize}; -use crate::deploy::{composefs_rollback, RequiredHostSpec}; -use crate::install::{ - pull_composefs_repo, setup_composefs_bls_boot, setup_composefs_uki_boot, write_composefs_state, - BootSetupType, BootType, +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::{ + rollback::composefs_rollback, status::composefs_booted, switch::switch_composefs, + update::upgrade_composefs, }; +use crate::deploy::RequiredHostSpec; use crate::lints; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; use crate::spec::ImageReference; -use crate::status::{composefs_booted, composefs_deployment_status}; use crate::utils::sigpolicy_from_opt; /// Shared progress options @@ -908,57 +908,6 @@ fn prepare_for_write() -> Result<()> { Ok(()) } -#[context("Upgrading composefs")] -async fn upgrade_composefs(_opts: UpgradeOpts) -> Result<()> { - // TODO: IMPORTANT Have all the checks here that `bootc upgrade` has for an ostree booted system - - let host = composefs_deployment_status() - .await - .context("Getting composefs deployment status")?; - - // TODO: IMPORTANT We need to check if any deployment is staged and get the image from that - let imgref = host - .spec - .image - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No image source specified"))?; - - let (repo, entries, id, fs) = pull_composefs_repo(&imgref.transport, &imgref.image).await?; - - let Some(entry) = entries.into_iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(&entry); - let mut boot_digest = None; - - match boot_type { - BootType::Bls => { - boot_digest = Some(setup_composefs_bls_boot( - BootSetupType::Upgrade((&fs, &host)), - repo, - &id, - entry, - )?) - } - - BootType::Uki => { - setup_composefs_uki_boot(BootSetupType::Upgrade((&fs, &host)), repo, &id, entry)? - } - }; - - write_composefs_state( - &Utf8PathBuf::from("/sysroot"), - id, - imgref, - true, - boot_type, - boot_digest, - )?; - - Ok(()) -} - /// Implementation of the `bootc upgrade` CLI command. #[context("Upgrading")] async fn upgrade(opts: UpgradeOpts) -> Result<()> { @@ -1072,7 +1021,7 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { Ok(()) } -fn imgref_for_switch(opts: &SwitchOpts) -> Result { +pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result { let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; let imgref = ostree_container::ImageReference { transport, @@ -1085,66 +1034,6 @@ fn imgref_for_switch(opts: &SwitchOpts) -> Result { return Ok(target); } -#[context("Composefs Switching")] -async fn switch_composefs(opts: SwitchOpts) -> Result<()> { - let target = imgref_for_switch(&opts)?; - // TODO: Handle in-place - - let host = composefs_deployment_status() - .await - .context("Getting composefs deployment status")?; - - let new_spec = { - let mut new_spec = host.spec.clone(); - new_spec.image = Some(target.clone()); - new_spec - }; - - if new_spec == host.spec { - println!("Image specification is unchanged."); - return Ok(()); - } - - let Some(target_imgref) = new_spec.image else { - anyhow::bail!("Target image is undefined") - }; - - let (repo, entries, id, fs) = - pull_composefs_repo(&"docker".into(), &target_imgref.image).await?; - - let Some(entry) = entries.into_iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(&entry); - let mut boot_digest = None; - - match boot_type { - BootType::Bls => { - boot_digest = Some(setup_composefs_bls_boot( - BootSetupType::Upgrade((&fs, &host)), - repo, - &id, - entry, - )?) - } - BootType::Uki => { - setup_composefs_uki_boot(BootSetupType::Upgrade((&fs, &host)), repo, &id, entry)? - } - }; - - write_composefs_state( - &Utf8PathBuf::from("/sysroot"), - id, - &target_imgref, - true, - boot_type, - boot_digest, - )?; - - Ok(()) -} - /// Implementation of the `bootc switch` CLI command. #[context("Switching")] async fn switch(opts: SwitchOpts) -> Result<()> { @@ -1240,29 +1129,21 @@ async fn switch(opts: SwitchOpts) -> Result<()> { /// Implementation of the `bootc rollback` CLI command. #[context("Rollback")] -async fn rollback(opts: RollbackOpts) -> Result<()> { - if composefs_booted()?.is_some() { - composefs_rollback().await? - } else { - let sysroot = &get_storage().await?; - let ostree = sysroot.get_ostree()?; - crate::deploy::rollback(sysroot).await?; - - if opts.soft_reboot.is_some() { - // Get status of rollback deployment to check soft-reboot capability - let host = crate::status::get_status_require_booted(ostree)?.2; - - handle_soft_reboot( - opts.soft_reboot, - host.status.rollback.as_ref(), - "rollback", - || soft_reboot_rollback(ostree), - )?; - } - }; +async fn rollback(opts: &RollbackOpts) -> Result<()> { + let sysroot = &get_storage().await?; + let ostree = sysroot.get_ostree()?; + crate::deploy::rollback(sysroot).await?; - if opts.apply { - crate::reboot::reboot()?; + if opts.soft_reboot.is_some() { + // Get status of rollback deployment to check soft-reboot capability + let host = crate::status::get_status_require_booted(ostree)?.2; + + handle_soft_reboot( + opts.soft_reboot, + host.status.rollback.as_ref(), + "rollback", + || soft_reboot_rollback(ostree), + )?; } Ok(()) @@ -1410,20 +1291,44 @@ async fn run_from_opt(opt: Opt) -> Result<()> { let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; match opt { Opt::Upgrade(opts) => { + #[cfg(feature = "composefs-backend")] if composefs_booted()?.is_some() { upgrade_composefs(opts).await } else { upgrade(opts).await } + + #[cfg(not(feature = "composefs-backend"))] + upgrade(opts).await } Opt::Switch(opts) => { + #[cfg(feature = "composefs-backend")] if composefs_booted()?.is_some() { switch_composefs(opts).await } else { switch(opts).await } + + #[cfg(not(feature = "composefs-backend"))] + switch(opts).await + } + Opt::Rollback(opts) => { + #[cfg(feature = "composefs-backend")] + if composefs_booted()?.is_some() { + composefs_rollback().await? + } else { + rollback(&opts).await? + } + + #[cfg(not(feature = "composefs-backend"))] + rollback(&opts).await?; + + if opts.apply { + crate::reboot::reboot()?; + } + + Ok(()) } - Opt::Rollback(opts) => rollback(opts).await, Opt::Edit(opts) => edit(opts).await, Opt::UsrOverlay => usroverlay().await, Opt::Container(opts) => match opts { diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 352eab0fa..238d9854f 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + /// composefs= paramter in kernel cmdline pub const COMPOSEFS_CMDLINE: &str = "composefs"; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 6184ec41c..1b25d0340 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -3,10 +3,7 @@ //! Create a merged filesystem tree with the image and mounted configmaps. use std::collections::HashSet; -use std::fmt::Write as _; -use std::fs::create_dir_all; -use std::io::{BufRead, Read, Write}; -use std::path::PathBuf; +use std::io::{BufRead, Write}; use anyhow::Ok; use anyhow::{anyhow, Context, Result}; @@ -23,18 +20,11 @@ use ostree_ext::ostree::Deployment; use ostree_ext::ostree::{self, Sysroot}; use ostree_ext::sysroot::SysrootLock; use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; -use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; - -use crate::composefs_consts::{ - BOOT_LOADER_ENTRIES, ROLLBACK_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_ROLLBACK, -}; -use crate::install::{get_efi_uuid_source, BootType}; -use crate::parsers::bls_config::{parse_bls_config, BLSConfig}; -use crate::parsers::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; + use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; use crate::spec::ImageReference; use crate::spec::{BootOrder, HostSpec}; -use crate::status::{composefs_deployment_status, labels_of_config}; +use crate::status::labels_of_config; use crate::store::Storage; use crate::utils::async_task_with_spinner; @@ -812,233 +802,6 @@ pub(crate) async fn stage( Ok(()) } -#[context("Rolling back UKI")] -pub(crate) fn rollback_composefs_uki() -> Result<()> { - let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); - - let mut str = String::new(); - let boot_dir = - cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) - .context("Opening boot dir")?; - let mut menuentries = - get_sorted_uki_boot_entries(&boot_dir, &mut str).context("Getting UKI boot entries")?; - - // TODO(Johan-Liebert): Currently assuming there are only two deployments - assert!(menuentries.len() == 2); - - let (first, second) = menuentries.split_at_mut(1); - std::mem::swap(&mut first[0], &mut second[0]); - - let mut buffer = get_efi_uuid_source(); - - for entry in menuentries { - write!(buffer, "{entry}")?; - } - - let entries_dir = - cap_std::fs::Dir::open_ambient_dir(&user_cfg_path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {user_cfg_path:?}"))?; - - entries_dir - .atomic_write(USER_CFG_ROLLBACK, buffer) - .with_context(|| format!("Writing to {USER_CFG_ROLLBACK}"))?; - - tracing::debug!("Atomically exchanging for {USER_CFG_ROLLBACK} and {USER_CFG}"); - renameat_with( - &entries_dir, - USER_CFG_ROLLBACK, - &entries_dir, - USER_CFG, - RenameFlags::EXCHANGE, - ) - .context("renameat")?; - - tracing::debug!("Removing {USER_CFG_ROLLBACK}"); - rustix::fs::unlinkat(&entries_dir, USER_CFG_ROLLBACK, AtFlags::empty()).context("unlinkat")?; - - tracing::debug!("Syncing to disk"); - fsync( - entries_dir - .reopen_as_ownedfd() - .with_context(|| format!("Reopening {user_cfg_path:?} as owned fd"))?, - ) - .with_context(|| format!("fsync {user_cfg_path:?}"))?; - - Ok(()) -} - -// Need str to store lifetime -pub(crate) fn get_sorted_uki_boot_entries<'a>( - boot_dir: &Dir, - str: &'a mut String, -) -> Result>> { - let mut file = boot_dir - .open(format!("grub2/{USER_CFG}")) - .with_context(|| format!("Opening {USER_CFG}"))?; - file.read_to_string(str)?; - parse_grub_menuentry_file(str) -} - -#[context("Getting sorted BLS entries")] -pub(crate) fn get_sorted_bls_boot_entries( - boot_dir: &Dir, - ascending: bool, -) -> Result> { - let mut all_configs = vec![]; - - for entry in boot_dir.read_dir(format!("loader/{BOOT_LOADER_ENTRIES}"))? { - let entry = entry?; - - let file_name = entry.file_name(); - - let file_name = file_name - .to_str() - .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?; - - if !file_name.ends_with(".conf") { - continue; - } - - let mut file = entry - .open() - .with_context(|| format!("Failed to open {:?}", file_name))?; - - let mut contents = String::new(); - file.read_to_string(&mut contents) - .with_context(|| format!("Failed to read {:?}", file_name))?; - - let config = parse_bls_config(&contents).context("Parsing bls config")?; - - all_configs.push(config); - } - - all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); - - return Ok(all_configs); -} - -#[context("Rolling back BLS")] -pub(crate) fn rollback_composefs_bls() -> Result<()> { - let boot_dir = - cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) - .context("Opening boot dir")?; - - // Sort in descending order as that's the order they're shown on the boot screen - // After this: - // all_configs[0] -> booted depl - // all_configs[1] -> rollback depl - let mut all_configs = get_sorted_bls_boot_entries(&boot_dir, false)?; - - // Update the indicies so that they're swapped - for (idx, cfg) in all_configs.iter_mut().enumerate() { - cfg.sort_key = Some(idx.to_string()); - } - - // TODO(Johan-Liebert): Currently assuming there are only two deployments - assert!(all_configs.len() == 2); - - // Write these - let dir_path = PathBuf::from(format!( - "/sysroot/boot/loader/{ROLLBACK_BOOT_LOADER_ENTRIES}" - )); - create_dir_all(&dir_path).with_context(|| format!("Failed to create dir: {dir_path:?}"))?; - - let rollback_entries_dir = - cap_std::fs::Dir::open_ambient_dir(&dir_path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {dir_path:?}"))?; - - // Write the BLS configs in there - for cfg in all_configs { - // SAFETY: We set sort_key above - let file_name = format!("bootc-composefs-{}.conf", cfg.sort_key.as_ref().unwrap()); - - rollback_entries_dir - .atomic_write(&file_name, cfg.to_string()) - .with_context(|| format!("Writing to {file_name}"))?; - } - - // Should we sync after every write? - fsync( - rollback_entries_dir - .reopen_as_ownedfd() - .with_context(|| format!("Reopening {dir_path:?} as owned fd"))?, - ) - .with_context(|| format!("fsync {dir_path:?}"))?; - - // Atomically exchange "entries" <-> "entries.rollback" - let dir = Dir::open_ambient_dir("/sysroot/boot/loader", cap_std::ambient_authority()) - .context("Opening loader dir")?; - - tracing::debug!( - "Atomically exchanging for {ROLLBACK_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}" - ); - renameat_with( - &dir, - ROLLBACK_BOOT_LOADER_ENTRIES, - &dir, - BOOT_LOADER_ENTRIES, - RenameFlags::EXCHANGE, - ) - .context("renameat")?; - - tracing::debug!("Removing {ROLLBACK_BOOT_LOADER_ENTRIES}"); - rustix::fs::unlinkat(&dir, ROLLBACK_BOOT_LOADER_ENTRIES, AtFlags::empty()) - .context("unlinkat")?; - - tracing::debug!("Syncing to disk"); - fsync( - dir.reopen_as_ownedfd() - .with_context(|| format!("Reopening /sysroot/boot/loader as owned fd"))?, - ) - .context("fsync")?; - - Ok(()) -} - -#[context("Rolling back composefs")] -pub(crate) async fn composefs_rollback() -> Result<()> { - let host = composefs_deployment_status().await?; - - let new_spec = { - let mut new_spec = host.spec.clone(); - new_spec.boot_order = new_spec.boot_order.swap(); - new_spec - }; - - // Just to be sure - host.spec.verify_transition(&new_spec)?; - - let reverting = new_spec.boot_order == BootOrder::Default; - if reverting { - println!("notice: Reverting queued rollback state"); - } - - let rollback_status = host - .status - .rollback - .ok_or_else(|| anyhow!("No rollback available"))?; - - // TODO: Handle staged deployment - // Ostree will drop any staged deployment on rollback but will keep it if it is the first item - // in the new deployment list - let Some(rollback_composefs_entry) = &rollback_status.composefs else { - anyhow::bail!("Rollback deployment not a composefs deployment") - }; - - match rollback_composefs_entry.boot_type { - BootType::Bls => rollback_composefs_bls(), - BootType::Uki => rollback_composefs_uki(), - }?; - - if reverting { - println!("Next boot: current deployment"); - } else { - println!("Next boot: rollback deployment"); - } - - Ok(()) -} - /// Implementation of rollback functionality pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> { const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; @@ -1269,8 +1032,6 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { #[cfg(test)] mod tests { - use crate::parsers::grub_menuconfig::MenuentryBody; - use super::*; #[test] @@ -1365,117 +1126,4 @@ UUID=6907-17CA /boot/efi vfat umask=0077,shortname=win assert_eq!(tempdir.read_to_string("etc/fstab")?, modified); Ok(()) } - - #[test] - fn test_sorted_bls_boot_entries() -> Result<()> { - let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; - - let entry1 = r#" - title Fedora 42.20250623.3.1 (CoreOS) - version fedora-42.0 - sort-key 1 - linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 - initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img - options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 - "#; - - let entry2 = r#" - title Fedora 41.20250214.2.0 (CoreOS) - version fedora-42.0 - sort-key 2 - linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10 - initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img - options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01 - "#; - - tempdir.create_dir_all("loader/entries")?; - tempdir.atomic_write( - "loader/entries/random_file.txt", - "Random file that we won't parse", - )?; - tempdir.atomic_write("loader/entries/entry1.conf", entry1)?; - tempdir.atomic_write("loader/entries/entry2.conf", entry2)?; - - let result = get_sorted_bls_boot_entries(&tempdir, true).unwrap(); - - let mut config1 = BLSConfig::default(); - config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into()); - config1.sort_key = Some("1".into()); - config1.linux = "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(); - config1.initrd = vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()]; - config1.options = Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()); - - let mut config2 = BLSConfig::default(); - config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into()); - config2.sort_key = Some("2".into()); - config2.linux = "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(); - config2.initrd = vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()]; - config2.options = Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into()); - - assert_eq!(result[0].sort_key.as_ref().unwrap(), "1"); - assert_eq!(result[1].sort_key.as_ref().unwrap(), "2"); - - let result = get_sorted_bls_boot_entries(&tempdir, false).unwrap(); - assert_eq!(result[0].sort_key.as_ref().unwrap(), "2"); - assert_eq!(result[1].sort_key.as_ref().unwrap(), "1"); - - Ok(()) - } - - #[test] - fn test_sorted_uki_boot_entries() -> Result<()> { - let user_cfg = r#" - if [ -f ${config_directory}/efiuuid.cfg ]; then - source ${config_directory}/efiuuid.cfg - fi - - menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" { - insmod fat - insmod chain - search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" - chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi - } - - menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" { - insmod fat - insmod chain - search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" - chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi - } - "#; - - let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; - bootdir.create_dir_all(format!("grub2"))?; - bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?; - - let mut s = String::new(); - let result = get_sorted_uki_boot_entries(&bootdir, &mut s)?; - - let expected = vec![ - MenuEntry { - title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(), - body: MenuentryBody { - insmod: vec!["fat", "chain"], - chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(), - search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", - version: 0, - extra: vec![], - }, - }, - MenuEntry { - title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(), - body: MenuentryBody { - insmod: vec!["fat", "chain"], - chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), - search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", - version: 0, - extra: vec![], - }, - }, - ]; - - assert_eq!(result, expected); - - Ok(()) - } } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 709c46bb7..8daa42600 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -15,13 +15,10 @@ mod osbuild; pub(crate) mod osconfig; use std::collections::HashMap; -use std::ffi::OsStr; -use std::fs::create_dir_all; use std::io::Write; use std::os::fd::{AsFd, AsRawFd}; -use std::os::unix::fs::symlink; use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process; use std::process::Command; use std::str::FromStr; @@ -30,7 +27,6 @@ use std::time::Duration; use aleph::InstallAleph; use anyhow::{anyhow, ensure, Context, Result}; -use bootc_blockdev::{find_parent_devices, PartitionTable}; use bootc_utils::CommandRunExt; use camino::Utf8Path; use camino::Utf8PathBuf; @@ -43,68 +39,38 @@ use cap_std_ext::cap_tempfile::TempDir; use cap_std_ext::cmdext::CapStdExtCommandExt; use cap_std_ext::prelude::CapStdExtDirExt; use clap::ValueEnum; -use composefs::fs::read_file; -use composefs::tree::FileSystem; use fn_error_context::context; use ostree::gio; -use ostree_ext::composefs::{ - fsverity::{FsVerityHashValue, Sha256HashValue}, - repository::Repository as ComposefsRepository, - util::Sha256Digest, -}; -use ostree_ext::composefs_boot::bootloader::UsrLibModulesVmlinuz; -use ostree_ext::composefs_boot::{ - bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, - os_release::OsReleaseInfo, uki, BootOps, -}; -use ostree_ext::composefs_oci::{ - image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, -}; -use ostree_ext::container::deploy::ORIGIN_CONTAINER; use ostree_ext::ostree; use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate}; use ostree_ext::prelude::Cast; use ostree_ext::sysroot::SysrootLock; -use ostree_ext::{ - container as ostree_container, container::ImageReference as OstreeExtImgRef, ostree_prepareroot, -}; +use ostree_ext::{container as ostree_container, ostree_prepareroot}; #[cfg(feature = "install-to-disk")] use rustix::fs::FileTypeExt; use rustix::fs::MetadataExt as _; -use rustix::path::Arg; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[cfg(feature = "install-to-disk")] use self::baseline::InstallBlockDeviceOpts; -use crate::bootc_composefs::state::copy_etc_to_state; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::{boot::setup_composefs_boot, repo::initialize_composefs_repository}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; -use crate::composefs_consts::{ - BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, - SHARED_VAR_PATH, STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, STATE_DIR_RELATIVE, USER_CFG, - USER_CFG_STAGED, -}; use crate::containerenv::ContainerExecutionInfo; -use crate::deploy::{ - get_sorted_uki_boot_entries, prepare_for_pull, pull_from_prepared, PreparedImportMeta, - PreparedPullResult, -}; +use crate::deploy::{prepare_for_pull, pull_from_prepared, PreparedImportMeta, PreparedPullResult}; use crate::lsm; -use crate::parsers::bls_config::{parse_bls_config, BLSConfig}; -use crate::parsers::grub_menuconfig::MenuEntry; use crate::progress_jsonl::ProgressWriter; -use crate::spec::{Bootloader, Host, ImageReference}; +use crate::spec::{Bootloader, ImageReference}; use crate::store::Storage; use crate::task::Task; -use crate::utils::{path_relative_to, sigpolicy_from_opt}; +use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::Cmdline; -use bootc_mount::{inspect_filesystem, Filesystem}; +use bootc_mount::Filesystem; +#[cfg(feature = "composefs-backend")] +use composefs::fsverity::FsVerityHashValue; /// The toplevel boot directory const BOOT: &str = "boot"; -/// The EFI Linux directory -const EFI_LINUX: &str = "EFI/Linux"; /// Directory for transient runtime state #[cfg(feature = "install-to-disk")] const RUN_BOOTC: &str = "/run/bootc"; @@ -134,7 +100,7 @@ const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ ]; /// Kernel argument used to specify we want the rootfs mounted read-write by default -const RW_KARG: &str = "rw"; +pub(crate) const RW_KARG: &str = "rw"; #[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallTargetOpts { @@ -261,51 +227,6 @@ pub(crate) struct InstallConfigOpts { pub(crate) stateroot: Option, } -#[derive( - ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema, -)] -pub enum BootType { - #[default] - Bls, - Uki, -} - -impl ::std::fmt::Display for BootType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - BootType::Bls => "bls", - BootType::Uki => "uki", - }; - - write!(f, "{}", s) - } -} - -impl TryFrom<&str> for BootType { - type Error = anyhow::Error; - - fn try_from(value: &str) -> std::result::Result { - match value { - "bls" => Ok(Self::Bls), - "uki" => Ok(Self::Uki), - unrecognized => Err(anyhow::anyhow!( - "Unrecognized boot option: '{unrecognized}'" - )), - } - } -} - -impl From<&ComposefsBootEntry> for BootType { - fn from(entry: &ComposefsBootEntry) -> Self { - match entry { - ComposefsBootEntry::Type1(..) => Self::Bls, - ComposefsBootEntry::Type2(..) => Self::Uki, - ComposefsBootEntry::UsrLibModulesUki(..) => Self::Uki, - ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, - } - } -} - #[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallComposefsOpts { #[clap(long, default_value_t)] @@ -343,10 +264,12 @@ pub(crate) struct InstallToDiskOpts { #[clap(long)] #[serde(default)] + #[cfg(feature = "composefs-backend")] pub(crate) composefs_native: bool, #[clap(flatten)] #[serde(flatten)] + #[cfg(feature = "composefs-backend")] pub(crate) composefs_opts: InstallComposefsOpts, } @@ -490,6 +413,7 @@ pub(crate) struct State { pub(crate) tempdir: TempDir, // If Some, then --composefs_native is passed + #[cfg(feature = "composefs-backend")] pub(crate) composefs_options: Option, } @@ -617,7 +541,7 @@ impl FromStr for MountSpec { } } -#[cfg(feature = "install-to-disk")] +#[cfg(all(feature = "install-to-disk", feature = "composefs-backend"))] impl InstallToDiskOpts { pub(crate) fn validate(&self) -> Result<()> { if !self.composefs_native { @@ -1046,17 +970,17 @@ pub(crate) fn exec_in_host_mountns(args: &[std::ffi::OsString]) -> Result<()> { pub(crate) struct RootSetup { #[cfg(feature = "install-to-disk")] luks_device: Option, - device_info: bootc_blockdev::PartitionTable, + pub(crate) device_info: bootc_blockdev::PartitionTable, /// Absolute path to the location where we've mounted the physical /// root filesystem for the system we're installing. - physical_root_path: Utf8PathBuf, + pub(crate) physical_root_path: Utf8PathBuf, /// Directory file descriptor for the above physical root. - physical_root: Dir, - rootfs_uuid: Option, + pub(crate) physical_root: Dir, + pub(crate) rootfs_uuid: Option, /// True if we should skip finalizing skip_finalize: bool, boot: Option, - kargs: Vec, + pub(crate) kargs: Vec, } fn require_boot_uuid(spec: &MountSpec) -> Result<&str> { @@ -1067,7 +991,7 @@ fn require_boot_uuid(spec: &MountSpec) -> Result<&str> { impl RootSetup { /// Get the UUID= mount specifier for the /boot filesystem; if there isn't one, the root UUID will /// be returned. - fn get_boot_uuid(&self) -> Result> { + pub(crate) fn get_boot_uuid(&self) -> Result> { self.boot.as_ref().map(require_boot_uuid).transpose() } @@ -1276,7 +1200,7 @@ async fn prepare_install( config_opts: InstallConfigOpts, source_opts: InstallSourceOpts, target_opts: InstallTargetOpts, - composefs_opts: Option, + _composefs_opts: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1421,7 +1345,8 @@ async fn prepare_install( container_root: rootfs, tempdir, host_is_container, - composefs_options: composefs_opts, + #[cfg(feature = "composefs-backend")] + composefs_options: _composefs_opts, }); Ok(state) @@ -1520,877 +1445,44 @@ impl BoundImages { } } -pub(crate) fn open_composefs_repo( - rootfs_dir: &Dir, -) -> Result> { - ComposefsRepository::open_path(rootfs_dir, "composefs") - .context("Failed to open composefs repository") -} - -async fn initialize_composefs_repository( - state: &State, - root_setup: &RootSetup, -) -> Result<(Sha256Digest, impl FsVerityHashValue)> { - let rootfs_dir = &root_setup.physical_root; - - rootfs_dir - .create_dir_all("composefs") - .context("Creating dir composefs")?; - - let repo = open_composefs_repo(rootfs_dir)?; - - let OstreeExtImgRef { - name: image_name, - transport, - } = &state.source.imageref; - - // transport's display is already of type ":" - composefs_oci_pull( - &Arc::new(repo), - &format!("{transport}{image_name}"), - None, - None, - ) - .await -} - -fn get_booted_bls() -> Result { - let cmdline = Cmdline::from_proc()?; - let booted = cmdline - .find_str(COMPOSEFS_CMDLINE) - .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; - - for entry in std::fs::read_dir("/sysroot/boot/loader/entries")? { - let entry = entry?; - - if !entry.file_name().as_str()?.ends_with(".conf") { - continue; - } - - let bls = parse_bls_config(&std::fs::read_to_string(&entry.path())?)?; - - let Some(opts) = &bls.options else { - anyhow::bail!("options not found in bls config") - }; - - if opts.contains(booted.as_ref()) { - return Ok(bls); - } - } - - Err(anyhow::anyhow!("Booted BLS not found")) -} - -pub(crate) enum BootSetupType<'a> { - /// For initial setup, i.e. install to-disk - Setup((&'a RootSetup, &'a State, &'a FileSystem)), - /// For `bootc upgrade` - Upgrade((&'a FileSystem, &'a Host)), -} - -/// Compute SHA256Sum of VMlinuz + Initrd -/// -/// # Arguments -/// * entry - BootEntry containing VMlinuz and Initrd -/// * repo - The composefs repository -#[context("Computing boot digest")] -fn compute_boot_digest( - entry: &UsrLibModulesVmlinuz, - repo: &ComposefsRepository, -) -> Result { - let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?; - - let Some(initramfs) = &entry.initramfs else { - anyhow::bail!("initramfs not found"); - }; - - let initramfs = read_file(initramfs, &repo).context("Reading intird")?; - - let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) - .context("Creating hasher")?; - - hasher.update(&vmlinuz).context("hashing vmlinuz")?; - hasher.update(&initramfs).context("hashing initrd")?; - - let digest: &[u8] = &hasher.finish().context("Finishing digest")?; - - return Ok(hex::encode(digest)); -} - -/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum -/// -/// # Returns -/// Returns the verity of the deployment that has a boot digest same as the one passed in -#[context("Checking boot entry duplicates")] -fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { - let deployments = - cap_std::fs::Dir::open_ambient_dir(STATE_DIR_ABS, cap_std::ambient_authority()); - - let deployments = match deployments { - Ok(d) => d, - // The first ever deployment - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => anyhow::bail!(e), - }; - - let mut symlink_to: Option = None; - - for depl in deployments.entries()? { - let depl = depl?; - - let depl_file_name = depl.file_name(); - let depl_file_name = depl_file_name.as_str()?; - - let config = depl - .open_dir() - .with_context(|| format!("Opening {depl_file_name}"))? - .read_to_string(format!("{depl_file_name}.origin")) - .context("Reading origin file")?; - - let ini = tini::Ini::from_string(&config) - .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; - - match ini.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST) { - Some(hash) => { - if hash == digest { - symlink_to = Some(depl_file_name.to_string()); - break; - } - } - - // No SHASum recorded in origin file - // `symlink_to` is already none, but being explicit here - None => symlink_to = None, - }; - } - - Ok(symlink_to) -} - -#[context("Writing BLS entries to disk")] -fn write_bls_boot_entries_to_disk( - boot_dir: &Utf8PathBuf, - deployment_id: &Sha256HashValue, - entry: &UsrLibModulesVmlinuz, - repo: &ComposefsRepository, -) -> Result<()> { - let id_hex = deployment_id.to_hex(); - - // Write the initrd and vmlinuz at /boot// - let path = boot_dir.join(&id_hex); - create_dir_all(&path)?; - - let entries_dir = cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {path}"))?; - - entries_dir - .atomic_write( - "vmlinuz", - read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?, - ) - .context("Writing vmlinuz to path")?; - - let Some(initramfs) = &entry.initramfs else { - anyhow::bail!("initramfs not found"); - }; - - entries_dir - .atomic_write( - "initrd", - read_file(initramfs, &repo).context("Reading initrd")?, - ) - .context("Writing initrd to path")?; - - // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd - let owned_fd = entries_dir - .reopen_as_ownedfd() - .context("Reopen as owned fd")?; - - rustix::fs::fsync(owned_fd).context("fsync")?; - - Ok(()) -} - -struct BLSEntryPath<'a> { - /// Where to write vmlinuz/initrd - entries_path: Utf8PathBuf, - /// The absolute path, with reference to the partition's root, where the vmlinuz/initrd are written to - /// We need this as when installing, the mounted path will not - abs_entries_path: &'a str, - /// Where to write the .conf files - config_path: Utf8PathBuf, - /// If we mounted EFI, the target path - mount_path: Option, -} - -/// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk -/// -/// # Returns -/// Returns the SHA256Sum of VMLinuz + Initrd combo. Error if any -#[context("Setting up BLS boot")] -pub(crate) fn setup_composefs_bls_boot( - setup_type: BootSetupType, - // TODO: Make this generic - repo: ComposefsRepository, - id: &Sha256HashValue, - entry: ComposefsBootEntry, -) -> Result { - let id_hex = id.to_hex(); - - let (root_path, esp_device, cmdline_refs, fs, bootloader) = match setup_type { - BootSetupType::Setup((root_setup, state, fs)) => { - // root_setup.kargs has [root=UUID=, "rw"] - let mut cmdline_options = String::from(root_setup.kargs.join(" ")); - - match &state.composefs_options { - Some(opt) if opt.insecure => { - cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}=?{id_hex}")); - } - None | Some(..) => { - cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}={id_hex}")); - } - }; - - // 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"))?; - - ( - 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()), - ) - } - - BootSetupType::Upgrade((fs, host)) => { - let sysroot = Utf8PathBuf::from("/sysroot"); - - let fsinfo = inspect_filesystem(&sysroot)?; - let parent_devices = find_parent_devices(&fsinfo.source)?; - - let Some(parent) = parent_devices.into_iter().next() else { - anyhow::bail!("Could not find parent device for mountpoint /sysroot"); - }; - - let bootloader = host.require_composefs_booted()?.bootloader.clone(); - - ( - Utf8PathBuf::from("/sysroot"), - get_esp_partition(&parent)?.0, - vec![ - format!("root=UUID={DPS_UUID}"), - RW_KARG.to_string(), - format!("{COMPOSEFS_CMDLINE}={id_hex}"), - ] - .join(" "), - fs, - bootloader, - ) - } - }; - - let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); - - let (entry_paths, _tmpdir_guard) = match bootloader { - Bootloader::Grub => ( - BLSEntryPath { - entries_path: root_path.join("boot"), - config_path: root_path.join("boot"), - abs_entries_path: "boot", - mount_path: None, - }, - None, - ), - - Bootloader::Systemd => { - let temp_efi_dir = tempfile::tempdir().map_err(|e| { - anyhow::anyhow!("Failed to create temporary directory for EFI mount: {e}") - })?; - - let mounted_efi = Utf8PathBuf::from_path_buf(temp_efi_dir.path().to_path_buf()) - .map_err(|_| anyhow::anyhow!("EFI dir is not valid UTF-8"))?; - - Command::new("mount") - .args([&PathBuf::from(&esp_device), mounted_efi.as_std_path()]) - .log_debug() - .run_inherited_with_cmd_context() - .context("Mounting EFI")?; - - let efi_linux_dir = mounted_efi.join(EFI_LINUX); - - ( - BLSEntryPath { - entries_path: efi_linux_dir, - config_path: mounted_efi.clone(), - abs_entries_path: EFI_LINUX, - mount_path: Some(mounted_efi), - }, - Some(temp_efi_dir), - ) - } - }; - - let (bls_config, boot_digest) = match &entry { - ComposefsBootEntry::Type1(..) => unimplemented!(), - ComposefsBootEntry::Type2(..) => unimplemented!(), - ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), - - ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { - let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) - .context("Computing boot digest")?; - - // Every update should have its own /usr/lib/os-release - let (dir, fname) = fs - .root - .split(OsStr::new("/usr/lib/os-release")) - .context("Getting /usr/lib/os-release")?; - - let os_release = dir - .get_file_opt(fname) - .context("Getting /usr/lib/os-release")?; - - let version = os_release.and_then(|os_rel_file| { - let file_contents = match read_file(os_rel_file, &repo) { - Ok(c) => c, - Err(e) => { - tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); - return None; - } - }; - - let file_contents = match std::str::from_utf8(&file_contents) { - Ok(c) => c, - Err(..) => { - tracing::warn!("/usr/lib/os-release did not have valid UTF-8"); - return None; - } - }; - - OsReleaseInfo::parse(file_contents).get_version() - }); - - let default_sort_key = "1"; - - let mut bls_config = BLSConfig::default(); - - bls_config - .with_title(id_hex.clone()) - .with_sort_key(default_sort_key.into()) - .with_version(version.unwrap_or(default_sort_key.into())) - .with_linux(format!( - "/{}/{id_hex}/vmlinuz", - entry_paths.abs_entries_path - )) - .with_initrd(vec![format!( - "/{}/{id_hex}/initrd", - entry_paths.abs_entries_path - )]) - .with_options(cmdline_refs); - - if let Some(symlink_to) = find_vmlinuz_initrd_duplicates(&boot_digest)? { - bls_config.linux = - format!("/{}/{symlink_to}/vmlinuz", entry_paths.abs_entries_path); - - bls_config.initrd = vec![format!( - "/{}/{symlink_to}/initrd", - entry_paths.abs_entries_path - )]; - } else { - write_bls_boot_entries_to_disk( - &entry_paths.entries_path, - id, - usr_lib_modules_vmlinuz, - &repo, - )?; - } - - (bls_config, boot_digest) - } - }; - - let (config_path, booted_bls) = if is_upgrade { - let mut booted_bls = get_booted_bls()?; - booted_bls.sort_key = Some("0".into()); // entries are sorted by their filename in reverse order - - // This will be atomically renamed to 'loader/entries' on shutdown/reboot - ( - entry_paths - .config_path - .join("loader") - .join(STAGED_BOOT_LOADER_ENTRIES), - Some(booted_bls), - ) - } else { - ( - entry_paths - .config_path - .join("loader") - .join(BOOT_LOADER_ENTRIES), - None, - ) - }; - - create_dir_all(&config_path).with_context(|| format!("Creating {:?}", config_path))?; - - // Scope to allow for proper unmounting - { - let loader_entries_dir = - cap_std::fs::Dir::open_ambient_dir(&config_path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {config_path:?}"))?; - - loader_entries_dir.atomic_write( - // SAFETY: We set sort_key above - format!( - "bootc-composefs-{}.conf", - bls_config.sort_key.as_ref().unwrap() - ), - bls_config.to_string().as_bytes(), - )?; - - if let Some(booted_bls) = booted_bls { - loader_entries_dir.atomic_write( - // SAFETY: We set sort_key above - format!( - "bootc-composefs-{}.conf", - booted_bls.sort_key.as_ref().unwrap() - ), - booted_bls.to_string().as_bytes(), - )?; - } - - let owned_loader_entries_fd = loader_entries_dir - .reopen_as_ownedfd() - .context("Reopening as owned fd")?; - - rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?; - } - - if let Some(mounted_efi) = entry_paths.mount_path { - Command::new("umount") - .arg(mounted_efi) - .log_debug() - .run_inherited_with_cmd_context() - .context("Unmounting EFI")?; - } - - Ok(boot_digest) -} - -pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { - let device_info: PartitionTable = 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}"))?; - - Ok((esp.node, esp.uuid)) -} - -/// Contains the EFP's filesystem UUID. Used by grub -pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; - -/// Returns the beginning of the grub2/user.cfg file -/// where we source a file containing the ESPs filesystem UUID -pub(crate) fn get_efi_uuid_source() -> String { - format!( - r#" -if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then - source ${{config_directory}}/{EFI_UUID_FILE} -fi -"# - ) -} - -#[context("Setting up UKI boot")] -pub(crate) fn setup_composefs_uki_boot( - setup_type: BootSetupType, - // TODO: Make this generic - repo: ComposefsRepository, - id: &Sha256HashValue, - entry: ComposefsBootEntry, -) -> Result<()> { - let (root_path, esp_device, is_insecure_from_opts) = match setup_type { - BootSetupType::Setup((root_setup, state, ..)) => { - if let Some(v) = &state.config_opts.karg { - if v.len() > 0 { - tracing::warn!("kargs passed for UKI will be ignored"); - } - } - - 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"))?; - - ( - root_setup.physical_root_path.clone(), - esp_part.node.clone(), - state.composefs_options.as_ref().map(|x| x.insecure), - ) - } - - BootSetupType::Upgrade(..) => { - let sysroot = Utf8PathBuf::from("/sysroot"); - - let fsinfo = inspect_filesystem(&sysroot)?; - let parent_devices = find_parent_devices(&fsinfo.source)?; - - let Some(parent) = parent_devices.into_iter().next() else { - anyhow::bail!("Could not find parent device for mountpoint /sysroot"); - }; - - (sysroot, get_esp_partition(&parent)?.0, None) - } - }; - - let temp_efi_dir = tempfile::tempdir() - .map_err(|e| anyhow::anyhow!("Failed to create temporary directory for EFI mount: {e}"))?; - let mounted_efi = temp_efi_dir.path().to_path_buf(); - - Task::new("Mounting ESP", "mount") - .args([&PathBuf::from(&esp_device), &mounted_efi.clone()]) - .run()?; - - let boot_label = match entry { - ComposefsBootEntry::Type1(..) => unimplemented!(), - ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), - ComposefsBootEntry::UsrLibModulesVmLinuz(..) => unimplemented!(), - - ComposefsBootEntry::Type2(type2_entry) => { - let uki = read_file(&type2_entry.file, &repo).context("Reading UKI")?; - let cmdline = uki::get_cmdline(&uki).context("Getting UKI cmdline")?; - let (composefs_cmdline, insecure) = get_cmdline_composefs::(cmdline)?; - - // If the UKI cmdline does not match what the user has passed as cmdline option - // NOTE: This will only be checked for new installs and now upgrades/switches - if let Some(is_insecure_from_opts) = is_insecure_from_opts { - match is_insecure_from_opts { - true => { - if !insecure { - tracing::warn!( - "--insecure passed as option but UKI cmdline does not support it" - ) - } - } - - false => { - if insecure { - tracing::warn!("UKI cmdline has composefs set as insecure") - } - } - } - } - - let boot_label = uki::get_boot_label(&uki).context("Getting UKI boot label")?; - - if composefs_cmdline != *id { - anyhow::bail!( - "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {id:?})" - ); - } - - // Write the UKI to ESP - let efi_linux_path = mounted_efi.join(EFI_LINUX); - create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?; - - let efi_linux = - cap_std::fs::Dir::open_ambient_dir(&efi_linux_path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {efi_linux_path:?}"))?; - - efi_linux - .atomic_write(format!("{}.efi", id.to_hex()), uki) - .context("Writing UKI")?; - - rustix::fs::fsync( - efi_linux - .reopen_as_ownedfd() - .context("Reopening as owned fd")?, - ) - .context("fsync")?; - - boot_label - } - }; - - Command::new("umount") - .arg(&mounted_efi) - .log_debug() - .run_inherited_with_cmd_context() - .context("Unmounting ESP")?; - - let boot_dir = root_path.join("boot"); - create_dir_all(&boot_dir).context("Failed to create boot dir")?; - - let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); - - let efi_uuid_source = get_efi_uuid_source(); - - let user_cfg_name = if is_upgrade { - USER_CFG_STAGED - } else { - USER_CFG - }; - - let grub_dir = - cap_std::fs::Dir::open_ambient_dir(boot_dir.join("grub2"), cap_std::ambient_authority()) - .context("opening boot/grub2")?; - - // Iterate over all available deployments, and generate a menuentry for each - // - // TODO: We might find a staged deployment here - if is_upgrade { - let mut buffer = vec![]; - - // Shouldn't really fail so no context here - buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all( - MenuEntry::new(&boot_label, &id.to_hex()) - .to_string() - .as_bytes(), - )?; - - let mut str_buf = String::new(); - let boot_dir = cap_std::fs::Dir::open_ambient_dir(boot_dir, cap_std::ambient_authority()) - .context("Opening boot dir")?; - let entries = get_sorted_uki_boot_entries(&boot_dir, &mut str_buf)?; - - // Write out only the currently booted entry, which should be the very first one - // Even if we have booted into the second menuentry "boot entry", the default will be the - // first one - buffer.write_all(entries[0].to_string().as_bytes())?; - - grub_dir - .atomic_write(user_cfg_name, buffer) - .with_context(|| format!("Writing to {user_cfg_name}"))?; - - rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; - - return Ok(()); - } - - // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there - // This will be sourced by grub2/user.cfg to be used for `--fs-uuid` - let esp_uuid = Task::new("blkid for ESP UUID", "blkid") - .args(["-s", "UUID", "-o", "value", &esp_device]) - .read()?; - - grub_dir.atomic_write( - EFI_UUID_FILE, - format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(), - )?; - - // Write to grub2/user.cfg - let mut buffer = vec![]; - - // Shouldn't really fail so no context here - buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all( - MenuEntry::new(&boot_label, &id.to_hex()) - .to_string() - .as_bytes(), - )?; - - grub_dir - .atomic_write(user_cfg_name, buffer) - .with_context(|| format!("Writing to {user_cfg_name}"))?; - - rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; - - Ok(()) -} - -/// Pulls the `image` from `transport` into a composefs repository at /sysroot -/// Checks for boot entries in the image and returns them -#[context("Pulling composefs repository")] -pub(crate) async fn pull_composefs_repo( - transport: &String, - image: &String, -) -> Result<( - ComposefsRepository, - Vec>, - Sha256HashValue, - FileSystem, -)> { - let rootfs_dir = cap_std::fs::Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())?; - - let repo = open_composefs_repo(&rootfs_dir).context("Opening compoesfs repo")?; - - let (id, verity) = - composefs_oci_pull(&Arc::new(repo), &format!("{transport}:{image}"), None, None) - .await - .context("Pulling composefs repo")?; - - tracing::debug!( - "id = {id}, verity = {verity}", - id = hex::encode(id), - verity = verity.to_hex() - ); - - let repo = open_composefs_repo(&rootfs_dir)?; - let mut fs = create_composefs_filesystem(&repo, &hex::encode(id), None) - .context("Failed to create composefs filesystem")?; - - let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; - - Ok((repo, entries, id, fs)) -} - -#[context("Setting up composefs boot")] -fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) -> Result<()> { - let boot_uuid = root_setup +async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> { + // We verify this upfront because it's currently required by bootupd + let boot_uuid = rootfs .get_boot_uuid()? - .or(root_setup.rootfs_uuid.as_deref()) + .or(rootfs.rootfs_uuid.as_deref()) .ok_or_else(|| anyhow!("No uuid for boot/root"))?; + tracing::debug!("boot uuid={boot_uuid}"); - 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 { - crate::bootloader::install_via_bootupd( - &root_setup.device_info, - &root_setup.physical_root_path, - &state.config_opts, - None, - )?; - } - - let repo = open_composefs_repo(&root_setup.physical_root)?; - - let mut fs = create_composefs_filesystem(&repo, image_id, None)?; - - let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; - - let Some(entry) = entries.into_iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(&entry); - let mut boot_digest: Option = None; - - match boot_type { - BootType::Bls => { - let digest = setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state, &fs)), - repo, - &id, - entry, - )?; - - boot_digest = Some(digest); - } - BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Setup((&root_setup, &state, &fs)), - repo, - &id, - entry, - )?, - }; - - write_composefs_state( - &root_setup.physical_root_path, - id, - &ImageReference { - image: state.source.imageref.name.clone(), - transport: state.source.imageref.transport.to_string(), - signature: None, - }, - false, - boot_type, - boot_digest, - )?; - - Ok(()) -} - -/// Creates and populates /sysroot/state/deploy/image_id -#[context("Writing composefs state")] -pub(crate) fn write_composefs_state( - root_path: &Utf8PathBuf, - deployment_id: Sha256HashValue, - imgref: &ImageReference, - staged: bool, - boot_type: BootType, - boot_digest: Option, -) -> Result<()> { - let state_path = root_path.join(format!("{STATE_DIR_RELATIVE}/{}", deployment_id.to_hex())); - - create_dir_all(state_path.join("etc"))?; - - copy_etc_to_state(&root_path, &deployment_id.to_hex(), &state_path)?; - - let actual_var_path = root_path.join(SHARED_VAR_PATH); - create_dir_all(&actual_var_path)?; - - symlink( - path_relative_to(state_path.as_std_path(), actual_var_path.as_std_path()) - .context("Getting var symlink path")?, - state_path.join("var"), - ) - .context("Failed to create symlink for /var")?; - - let ImageReference { - image: image_name, - transport, - .. - } = &imgref; - - let mut config = tini::Ini::new().section("origin").item( - ORIGIN_CONTAINER, - format!("ostree-unverified-image:{transport}{image_name}"), - ); - - config = config - .section(ORIGIN_KEY_BOOT) - .item(ORIGIN_KEY_BOOT_TYPE, boot_type); - - if let Some(boot_digest) = boot_digest { - config = config - .section(ORIGIN_KEY_BOOT) - .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest); - } + let bound_images = BoundImages::from_state(state).await?; - let state_dir = cap_std::fs::Dir::open_ambient_dir(&state_path, cap_std::ambient_authority()) - .context("Opening state dir")?; + // Initialize the ostree sysroot (repo, stateroot, etc.) - state_dir - .atomic_write( - format!("{}.origin", deployment_id.to_hex()), - config.to_string().as_bytes(), + { + let (sysroot, has_ostree) = initialize_ostree_root(state, rootfs).await?; + + install_with_sysroot( + state, + rootfs, + &sysroot, + &boot_uuid, + bound_images, + has_ostree, ) - .context("Falied to write to .origin file")?; + .await?; + let ostree = sysroot.get_ostree()?; - if staged { - std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) - .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + if matches!(cleanup, Cleanup::TriggerOnNextBoot) { + let sysroot_dir = crate::utils::sysroot_dir(ostree)?; + tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}"); + sysroot_dir.atomic_write(format!("etc/{}", DESTRUCTIVE_CLEANUP), b"")?; + } - let staged_depl_dir = cap_std::fs::Dir::open_ambient_dir( - COMPOSEFS_TRANSIENT_STATE_DIR, - cap_std::ambient_authority(), - ) - .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + // We must drop the sysroot here in order to close any open file + // descriptors. + }; - staged_depl_dir - .atomic_write( - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - deployment_id.to_hex().as_bytes(), - ) - .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; - } + // Run this on every install as the penultimate step + install_finalize(&rootfs.physical_root_path).await?; Ok(()) } @@ -2418,57 +1510,20 @@ async fn install_to_filesystem_impl( } } - // We verify this upfront because it's currently required by bootupd - let boot_uuid = rootfs - .get_boot_uuid()? - .or(rootfs.rootfs_uuid.as_deref()) - .ok_or_else(|| anyhow!("No uuid for boot/root"))?; - tracing::debug!("boot uuid={boot_uuid}"); - - let bound_images = BoundImages::from_state(state).await?; - + #[cfg(feature = "composefs-backend")] if state.composefs_options.is_some() { // Load a fd for the mounted target physical root - let (id, verity) = initialize_composefs_repository(state, rootfs).await?; - - tracing::info!( - "id = {id}, verity = {verity}", - id = hex::encode(id), - verity = verity.to_hex() - ); + let (id, verity) = initialize_composefs_repository(state, rootfs).await?; + tracing::info!("id: {}, verity: {}", hex::encode(id), verity.to_hex()); setup_composefs_boot(rootfs, state, &hex::encode(id))?; } else { - // Initialize the ostree sysroot (repo, stateroot, etc.) - - { - let (sysroot, has_ostree) = initialize_ostree_root(state, rootfs).await?; - - install_with_sysroot( - state, - rootfs, - &sysroot, - &boot_uuid, - bound_images, - has_ostree, - ) - .await?; - let ostree = sysroot.get_ostree()?; - - if matches!(cleanup, Cleanup::TriggerOnNextBoot) { - let sysroot_dir = crate::utils::sysroot_dir(ostree)?; - tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}"); - sysroot_dir.atomic_write(format!("etc/{}", DESTRUCTIVE_CLEANUP), b"")?; - } - - // We must drop the sysroot here in order to close any open file - // descriptors. - }; - - // Run this on every install as the penultimate step - install_finalize(&rootfs.physical_root_path).await?; + ostree_install(state, rootfs, cleanup).await?; } + #[cfg(not(feature = "composefs-backend"))] + ostree_install(state, rootfs, cleanup).await?; + // Finalize mounted filesystems if !rootfs.skip_finalize { let bootfs = rootfs.boot.as_ref().map(|_| ("boot", "boot")); @@ -2488,6 +1543,7 @@ fn installation_complete() { #[context("Installing to disk")] #[cfg(feature = "install-to-disk")] pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { + #[cfg(feature = "composefs-backend")] opts.validate()?; // Log the disk installation operation to systemd journal @@ -2531,15 +1587,22 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { } else if !target_blockdev_meta.file_type().is_block_device() { anyhow::bail!("Not a block device: {}", block_opts.device); } + + #[cfg(feature = "composefs-backend")] + let composefs_arg = if opts.composefs_native { + Some(opts.composefs_opts) + } else { + None + }; + + #[cfg(not(feature = "composefs-backend"))] + let composefs_arg = None; + let state = prepare_install( opts.config_opts, opts.source_opts, opts.target_opts, - if opts.composefs_native { - Some(opts.composefs_opts) - } else { - None - }, + composefs_arg, ) .await?; diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index e2fbf0f98..cb4f9e5b3 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -4,6 +4,7 @@ //! to provide a fully "container native" tool for using //! bootable container images. +#[cfg(feature = "composefs-backend")] mod bootc_composefs; pub(crate) mod bootc_kargs; mod bootloader; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 1b9d6c513..29b8f3e7b 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -2,6 +2,8 @@ //! //! This module parses the config files for the spec. +#![allow(dead_code)] + use anyhow::{anyhow, Result}; use std::collections::HashMap; use std::fmt::Display; diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index b5e8f4e75..f51b2eb29 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -1,5 +1,7 @@ //! Parser for GRUB menuentry configuration files using nom combinators. +#![allow(dead_code)] + use std::fmt::Display; use nom::{ diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 76b605582..b59877e43 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -11,7 +11,8 @@ use ostree_ext::{container::OstreeImageReference, oci_spec}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::install::BootType; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::boot::BootType; use crate::{k8sapitypes, status::Slot}; const API_VERSION: &str = "org.containers.bootc/v1"; @@ -198,6 +199,7 @@ impl FromStr for Bootloader { /// A bootable entry #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "camelCase")] +#[cfg(feature = "composefs-backend")] pub struct BootEntryComposefs { /// The erofs verity pub verity: String, @@ -228,6 +230,7 @@ pub struct BootEntry { /// If this boot entry is ostree based, the corresponding state pub ostree: Option, /// If this boot entry is composefs based, the corresponding state + #[cfg(feature = "composefs-backend")] pub composefs: Option, } @@ -300,6 +303,7 @@ impl Host { } } + #[cfg(feature = "composefs-backend")] pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> { let cfs = self .status @@ -582,6 +586,7 @@ mod tests { pinned: false, store: None, ostree: None, + #[cfg(feature = "composefs-backend")] composefs: None, } } diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 0ea8ba9e3..22741e2b2 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -3,43 +3,25 @@ use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; use std::io::Write; -use std::str::FromStr; -use std::sync::OnceLock; use anyhow::{Context, Result}; -use bootc_kernel_cmdline::Cmdline; -use bootc_utils::try_deserialize_timestamp; use canon_json::CanonJsonSerialize; -use cap_std_ext::cap_std; -use cap_std_ext::cap_std::ambient_authority; -use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; use ostree::glib; use ostree_container::OstreeImageReference; use ostree_ext::container as ostree_container; -use ostree_ext::container::deploy::ORIGIN_CONTAINER; use ostree_ext::container_utils::ostree_booted; -use ostree_ext::containers_image_proxy; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::oci_spec; use ostree_ext::oci_spec::image::Digest; use ostree_ext::oci_spec::image::ImageConfiguration; use ostree_ext::sysroot::SysrootLock; -use ostree_ext::oci_spec::image::ImageManifest; use ostree_ext::ostree; -use tokio::io::AsyncReadExt; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::status::{composefs_booted, composefs_deployment_status}; use crate::cli::OutputFormat; -use crate::composefs_consts::{ - COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, - ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, -}; -use crate::deploy::get_sorted_bls_boot_entries; -use crate::deploy::get_sorted_uki_boot_entries; -use crate::install::BootType; -use crate::install::EFIVARFS; -use crate::spec::Bootloader; use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; use crate::spec::{ImageReference, ImageSignature}; @@ -67,49 +49,6 @@ impl From for ostree_container::SignatureSource { } } -/// A parsed composefs command line -pub(crate) struct ComposefsCmdline { - #[allow(dead_code)] - pub insecure: bool, - pub digest: Box, -} - -impl ComposefsCmdline { - pub(crate) fn new(s: &str) -> Self { - let (insecure, digest_str) = s - .strip_prefix('?') - .map(|v| (true, v)) - .unwrap_or_else(|| (false, s)); - ComposefsCmdline { - insecure, - digest: digest_str.into(), - } - } -} - -impl std::fmt::Display for ComposefsCmdline { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let insecure = if self.insecure { "?" } else { "" }; - write!(f, "{}={}{}", COMPOSEFS_CMDLINE, insecure, self.digest) - } -} - -/// Detect if we have composefs= in /proc/cmdline -pub(crate) fn composefs_booted() -> Result> { - static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); - if let Some(v) = CACHED_DIGEST_VALUE.get() { - return Ok(v.as_ref()); - } - let cmdline = Cmdline::from_proc()?; - let Some(kv) = cmdline.find_str(COMPOSEFS_CMDLINE) else { - return Ok(None); - }; - let Some(v) = kv.value else { return Ok(None) }; - let v = ComposefsCmdline::new(v); - let r = CACHED_DIGEST_VALUE.get_or_init(|| Some(v)); - Ok(r.as_ref()) -} - /// Fixme lower serializability into ostree-ext fn transport_to_string(transport: ostree_container::Transport) -> String { match transport { @@ -271,6 +210,7 @@ fn boot_entry_from_deployment( deploy_serial: deployment.deployserial().try_into().unwrap(), stateroot: deployment.stateroot().into(), }), + #[cfg(feature = "composefs-backend")] composefs: None, }; Ok(r) @@ -400,244 +340,33 @@ pub(crate) fn get_status( Ok((deployments, host)) } -/// imgref = transport:image_name -#[context("Getting container info")] -async fn get_container_manifest_and_config( - imgref: &String, -) -> Result<(ImageManifest, oci_spec::image::ImageConfiguration)> { - let config = containers_image_proxy::ImageProxyConfig::default(); - let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?; - - let img = proxy.open_image(&imgref).await.context("Opening image")?; - - let (_, manifest) = proxy.fetch_manifest(&img).await?; - let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?; - - let mut buf = Vec::with_capacity(manifest.config().size() as usize); - buf.resize(manifest.config().size() as usize, 0); - reader.read_exact(&mut buf).await?; - driver.await?; - - let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?; - - Ok((manifest, config)) -} - -#[context("Getting bootloader")] -fn get_bootloader() -> Result { - let efivarfs = match Dir::open_ambient_dir(EFIVARFS, ambient_authority()) { - Ok(dir) => dir, - // Most likely using BIOS - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::Grub), - Err(e) => Err(e).context(format!("Opening {EFIVARFS}"))?, - }; - - const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; - - match efivarfs.read_to_string(EFI_LOADER_INFO) { - Ok(loader) => { - if loader.to_lowercase().contains("systemd-boot") { - return Ok(Bootloader::Systemd); - } - - return Ok(Bootloader::Grub); - } - - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::Grub), - - Err(e) => Err(e).context(format!("Opening {EFI_LOADER_INFO}"))?, - } -} - -#[context("Getting composefs deployment metadata")] -async fn boot_entry_from_composefs_deployment( - origin: tini::Ini, - verity: String, -) -> Result { - let image = match origin.get::("origin", ORIGIN_CONTAINER) { - Some(img_name_from_config) => { - let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; - let imgref = ostree_img_ref.imgref.to_string(); - let img_ref = ImageReference::from(ostree_img_ref); - - // The image might've been removed, so don't error if we can't get the image manifest - let (image_digest, version, architecture, created_at) = - match get_container_manifest_and_config(&imgref).await { - Ok((manifest, config)) => { - let digest = manifest.config().digest().to_string(); - let arch = config.architecture().to_string(); - let created = config.created().clone(); - let version = manifest - .annotations() - .as_ref() - .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned()); - - (digest, version, arch, created) - } - - Err(e) => { - tracing::debug!("Failed to open image {img_ref}, because {e:?}"); - ("".into(), None, "".into(), None) - } - }; - - let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x)); - - let image_status = ImageStatus { - image: img_ref, - version, - timestamp, - image_digest, - architecture, - }; - - Some(image_status) - } - - // Wasn't booted using a container image. Do nothing - None => None, - }; - - let boot_type = match origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) { - Some(s) => BootType::try_from(s.as_str())?, - None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"), - }; - - let e = BootEntry { - image, - cached_update: None, - incompatible: false, - pinned: false, - store: None, - ostree: None, - composefs: Some(crate::spec::BootEntryComposefs { - verity, - boot_type, - bootloader: get_bootloader()?, - }), - soft_reboot_capable: false, +#[cfg(feature = "composefs-backend")] +async fn get_host() -> Result { + let host = if ostree_booted()? { + let sysroot = super::cli::get_storage().await?; + let ostree = sysroot.get_ostree()?; + let booted_deployment = ostree.booted_deployment(); + let (_deployments, host) = get_status(&ostree, booted_deployment.as_ref())?; + host + } else if composefs_booted()?.is_some() { + composefs_deployment_status().await? + } else { + Default::default() }; - return Ok(e); + Ok(host) } -#[context("Getting composefs deployment status")] -pub(crate) async fn composefs_deployment_status() -> Result { - let composefs_state = composefs_booted()? - .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; - let composefs_digest = &composefs_state.digest; - - let sysroot = cap_std::fs::Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority()) - .context("Opening sysroot")?; - let deployments = sysroot - .read_dir(STATE_DIR_RELATIVE) - .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?; - - let host_spec = HostSpec { - image: None, - boot_order: BootOrder::Default, - }; - - let mut host = Host::new(host_spec); - - let staged_deployment_id = match std::fs::File::open(format!( - "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" - )) { - Ok(mut f) => { - let mut s = String::new(); - f.read_to_string(&mut s)?; - - Ok(Some(s)) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - }?; - - // NOTE: This cannot work if we support both BLS and UKI at the same time - let mut boot_type: Option = None; - - for depl in deployments { - let depl = depl?; - - let depl_file_name = depl.file_name(); - let depl_file_name = depl_file_name.to_string_lossy(); - - // read the origin file - let config = depl - .open_dir() - .with_context(|| format!("Failed to open {depl_file_name}"))? - .read_to_string(format!("{depl_file_name}.origin")) - .with_context(|| format!("Reading file {depl_file_name}.origin"))?; - - let ini = tini::Ini::from_string(&config) - .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; - - let boot_entry = - boot_entry_from_composefs_deployment(ini, depl_file_name.to_string()).await?; - - // SAFETY: boot_entry.composefs will always be present - let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; - - match boot_type { - Some(current_type) => { - if current_type != boot_type_from_origin { - anyhow::bail!("Conflicting boot types") - } - } - - None => { - boot_type = Some(boot_type_from_origin); - } - }; - - if depl.file_name() == composefs_digest.as_ref() { - host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); - host.status.booted = Some(boot_entry); - continue; - } - - if let Some(staged_deployment_id) = &staged_deployment_id { - if depl_file_name == staged_deployment_id.trim() { - host.status.staged = Some(boot_entry); - continue; - } - } - - host.status.rollback = Some(boot_entry); - } - - // Shouldn't really happen, but for sanity nonetheless - let Some(boot_type) = boot_type else { - anyhow::bail!("Could not determine boot type"); - }; - - let boot_dir = sysroot.open_dir("boot").context("Opening boot dir")?; - - match boot_type { - BootType::Bls => { - host.status.rollback_queued = !get_sorted_bls_boot_entries(&boot_dir, false)? - .first() - .ok_or(anyhow::anyhow!("First boot entry not found"))? - .options - .as_ref() - .ok_or(anyhow::anyhow!("options key not found in bls config"))? - .contains(composefs_digest.as_ref()); - } - - BootType::Uki => { - let mut s = String::new(); - - host.status.rollback_queued = !get_sorted_uki_boot_entries(&boot_dir, &mut s)? - .first() - .ok_or(anyhow::anyhow!("First boot entry not found"))? - .body - .chainloader - .contains(composefs_digest.as_ref()) - } - }; - - if host.status.rollback_queued { - host.spec.boot_order = BootOrder::Rollback +#[cfg(not(feature = "composefs-backend"))] +async fn get_host() -> Result { + let host = if ostree_booted()? { + let sysroot = super::cli::get_storage().await?; + let ostree = sysroot.get_ostree()?; + let booted_deployment = ostree.booted_deployment(); + let (_deployments, host) = get_status(&ostree, booted_deployment.as_ref())?; + host + } else { + Default::default() }; Ok(host) @@ -651,17 +380,7 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { 0 | 1 => {} o => anyhow::bail!("Unsupported format version: {o}"), }; - let mut host = if ostree_booted()? { - let sysroot = super::cli::get_storage().await?; - let ostree = sysroot.get_ostree()?; - let booted_deployment = ostree.booted_deployment(); - let (_deployments, host) = get_status(&ostree, booted_deployment.as_ref())?; - host - } else if composefs_booted()?.is_some() { - composefs_deployment_status().await? - } else { - Default::default() - }; + let mut host = get_host().await?; // We could support querying the staged or rollback deployments // here too, but it's not a common use case at the moment. @@ -796,6 +515,7 @@ fn human_render_slot( writeln!(out, "{digest} ({arch})")?; // Write the EROFS verity if present + #[cfg(feature = "composefs-backend")] if let Some(composefs) = &entry.composefs { write_row_name(&mut out, "Verity", prefix_len)?; writeln!(out, "{}", composefs.verity)?; @@ -902,6 +622,7 @@ fn human_render_slot_ostree( } /// Output a rendering of a non-container composefs boot entry. +#[cfg(feature = "composefs-backend")] fn human_render_slot_composefs( mut out: impl Write, slot: Slot, @@ -935,6 +656,8 @@ fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) } else { writeln!(out)?; } + + #[cfg(feature = "composefs-backend")] if let Some(image) = &host_status.image { human_render_slot(&mut out, Some(slot_name), host_status, image, verbose)?; } else if let Some(ostree) = host_status.ostree.as_ref() { @@ -950,6 +673,21 @@ fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) } else { writeln!(out, "Current {slot_name} state is unknown")?; } + + #[cfg(not(feature = "composefs-backend"))] + if let Some(image) = &host_status.image { + human_render_slot(&mut out, Some(slot_name), host_status, image, verbose)?; + } else if let Some(ostree) = host_status.ostree.as_ref() { + human_render_slot_ostree( + &mut out, + Some(slot_name), + host_status, + &ostree.checksum, + verbose, + )?; + } else { + writeln!(out, "Current {slot_name} state is unknown")?; + } } } @@ -1141,15 +879,4 @@ mod tests { assert!(w.contains("Commit:")); assert!(w.contains("Soft-reboot:")); } - - #[test] - fn test_composefs_parsing() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; - let v = ComposefsCmdline::new(DIGEST); - assert!(!v.insecure); - assert_eq!(v.digest.as_ref(), DIGEST); - let v = ComposefsCmdline::new(&format!("?{}", DIGEST)); - assert!(v.insecure); - assert_eq!(v.digest.as_ref(), DIGEST); - } } diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index 0a9e8ac57..712a1fc1f 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -1,9 +1,10 @@ +use std::future::Future; use std::io::Write; use std::os::fd::BorrowedFd; -use std::path::{Path, PathBuf}; +#[cfg(feature = "composefs-backend")] +use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::Duration; -use std::{future::Future, path::Component}; use anyhow::{Context, Result}; use bootc_utils::CommandRunExt; @@ -190,6 +191,7 @@ pub(crate) fn digested_pullspec(image: &str, digest: &str) -> String { /// Computes a relative path from `from` to `to`. /// /// Both `from` and `to` must be absolute paths. +#[cfg(feature = "composefs-backend")] pub(crate) fn path_relative_to(from: &Path, to: &Path) -> Result { if !from.is_absolute() || !to.is_absolute() { anyhow::bail!("Paths must be absolute"); @@ -248,6 +250,7 @@ mod tests { } #[test] + #[cfg(feature = "composefs-backend")] fn test_relative_path() { let from = Path::new("/sysroot/state/deploy/image_id"); let to = Path::new("/sysroot/state/os/default/var");