diff --git a/Cargo.lock b/Cargo.lock index c4e61bc73..a15cf90b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,7 @@ dependencies = [ "anstream", "anstyle", "anyhow", + "bootc-initramfs-setup", "bootc-internal-blockdev", "bootc-internal-utils", "bootc-kernel-cmdline", @@ -311,11 +312,13 @@ dependencies = [ "anyhow", "bootc-internal-utils", "camino", + "cap-std-ext", "fn-error-context", "indoc", "libc", "rustix 1.0.8", "serde", + "tempfile", "tracing", ] diff --git a/Makefile b/Makefile index 5a8fedd8e..03708b0a7 100644 --- a/Makefile +++ b/Makefile @@ -113,6 +113,7 @@ validate-rust: cargo test --no-run (cd crates/ostree-ext && cargo check --no-default-features) (cd crates/lib && cargo check --no-default-features) + cargo check --features=composefs-backend cargo clippy -- $(CLIPPY_CONFIG) env RUSTDOCFLAGS='-D warnings' cargo doc --lib .PHONY: validate-rust diff --git a/crates/etc-merge/src/lib.rs b/crates/etc-merge/src/lib.rs index b7a09247c..86cea1c8e 100644 --- a/crates/etc-merge/src/lib.rs +++ b/crates/etc-merge/src/lib.rs @@ -20,7 +20,9 @@ use cap_std_ext::dirext::CapStdExtDirExt; use composefs::fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}; use composefs::generic_tree::{Directory, Inode, Leaf, LeafContent, Stat}; use composefs::tree::ImageError; -use rustix::fs::{AtFlags, Gid, Uid, XattrFlags, lgetxattr, llistxattr, lsetxattr, readlinkat}; +use rustix::fs::{ + AtFlags, Gid, Uid, XattrFlags, lgetxattr, llistxattr, lsetxattr, readlinkat, symlinkat, +}; /// Metadata associated with a file, directory, or symlink entry. #[derive(Debug)] @@ -627,9 +629,8 @@ fn merge_leaf( .context(format!("Deleting {file:?}"))?; if let Some(target) = symlink { - new_etc_fd - .symlink(target.as_ref(), &file) - .context(format!("Creating symlink {file:?}"))?; + // Using rustix's symlinkat here as we might have absolute symlinks which clash with ambient_authority + symlinkat(&**target, new_etc_fd, file).context(format!("Creating symlink {file:?}"))?; } else { current_etc_fd .copy(&file, new_etc_fd, &file) diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 005917f4b..802ac6409 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -112,8 +112,9 @@ pub fn mount_at_wrapper( .with_context(|| format!("Mounting at path {path:?}")) } +/// Wrapper around [`rustix::fs::openat`] #[context("Opening dir {name:?}")] -fn open_dir(dirfd: impl AsFd, name: impl AsRef + Debug) -> Result { +pub fn open_dir(dirfd: impl AsFd, name: impl AsRef + Debug) -> Result { let res = openat( dirfd, name.as_ref(), diff --git a/crates/kernel_cmdline/src/lib.rs b/crates/kernel_cmdline/src/lib.rs index e984f0336..da5ef7f67 100644 --- a/crates/kernel_cmdline/src/lib.rs +++ b/crates/kernel_cmdline/src/lib.rs @@ -12,3 +12,8 @@ pub mod bytes; pub mod utf8; + +/// This is used by dracut. +pub const INITRD_ARG_PREFIX: &str = "rd."; +/// The kernel argument for configuring the rootfs flags. +pub const ROOTFLAGS: &str = "rootflags"; diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 998ea0e6a..aa6701557 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -23,6 +23,7 @@ bootc-tmpfiles = { path = "../tmpfiles" } bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } ostree-ext = { path = "../ostree-ext", features = ["bootc"] } etc-merge = { path = "../etc-merge" } +bootc-initramfs-setup = { path = "../initramfs" } # Workspace dependencies anstream = { workspace = true } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs new file mode 100644 index 000000000..879613482 --- /dev/null +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -0,0 +1,896 @@ +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::{ambient_authority, fs::Dir}, + dirext::CapStdExtDirExt, +}; +use clap::ValueEnum; +use composefs::fs::read_file; +use composefs::tree::{FileSystem, RegularFile}; +use composefs_boot::bootloader::{PEType, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT}; +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::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)) +} + +pub fn get_sysroot_parent_dev() -> Result { + 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"); + }; + + return Ok(parent); +} + +/// 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 = Dir::open_ambient_dir(STATE_DIR_ABS, 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 = Dir::open_ambient_dir(&path, 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_parent = get_sysroot_parent_dev()?; + let bootloader = host.require_composefs_booted()?.bootloader.clone(); + + ( + Utf8PathBuf::from("/sysroot"), + get_esp_partition(&sysroot_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::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 = Dir::open_ambient_dir(&config_path, 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) +} + +/// Writes a PortableExecutable to ESP along with any PE specific or Global addons +fn write_pe_to_esp( + repo: &ComposefsRepository, + file: &RegularFile, + file_path: &PathBuf, + pe_type: PEType, + uki_id: &String, + is_insecure_from_opts: bool, + mounted_efi: &PathBuf, +) -> Result> { + let efi_bin = read_file(file, &repo).context("Reading .efi binary")?; + + let mut boot_label = None; + + // UKI Extension might not even have a cmdline + // TODO: UKI Addon might also have a composefs= cmdline? + if matches!(pe_type, PEType::Uki) { + let cmdline = uki::get_cmdline(&efi_bin).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 + 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"); + } + + _ => { /* no-op */ } + } + + if composefs_cmdline.to_hex() != *uki_id { + anyhow::bail!( + "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})" + ); + } + + boot_label = Some(uki::get_boot_label(&efi_bin).context("Getting UKI boot label")?); + } + + // 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 final_pe_path = match file_path.parent() { + Some(parent) => { + let renamed_path = match parent.as_str()?.ends_with(EFI_ADDON_DIR_EXT) { + true => { + let dir_name = format!("{}{}", uki_id, EFI_ADDON_DIR_EXT); + + parent + .parent() + .map(|p| p.join(&dir_name)) + .unwrap_or(dir_name.into()) + } + + false => parent.to_path_buf(), + }; + + let full_path = efi_linux_path.join(renamed_path); + create_dir_all(&full_path)?; + + full_path + } + + None => efi_linux_path, + }; + + let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) + .with_context(|| format!("Opening {final_pe_path:?}"))?; + + let pe_name = match pe_type { + PEType::Uki => format!("{}{}", uki_id, EFI_EXT), + PEType::UkiAddon => format!("{}{}", uki_id, EFI_ADDON_FILE_EXT), + }; + + pe_dir + .atomic_write(pe_name, efi_bin) + .context("Writing UKI")?; + + rustix::fs::fsync( + pe_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?, + ) + .context("fsync")?; + + Ok(boot_label) +} + +#[context("Writing Grub menuentry")] +fn write_grub_uki_menuentry( + root_path: Utf8PathBuf, + setup_type: &BootSetupType, + boot_label: &String, + id: &Sha256HashValue, + esp_device: &String, +) -> Result<()> { + 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 = Dir::open_ambient_dir(boot_dir.join("grub2"), 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 = + Dir::open_ambient_dir(boot_dir, 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 UKI boot")] +pub(crate) fn setup_composefs_uki_boot( + setup_type: BootSetupType, + // TODO: Make this generic + repo: ComposefsRepository, + id: &Sha256HashValue, + entries: Vec>, +) -> Result<()> { + let (root_path, esp_device, bootloader, 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"))?; + + let bootloader = state + .composefs_options + .as_ref() + .map(|opts| opts.bootloader.clone()) + .unwrap_or(Bootloader::default()); + + let is_insecure = state + .composefs_options + .as_ref() + .map(|x| x.insecure) + .unwrap_or(false); + + ( + root_setup.physical_root_path.clone(), + esp_part.node.clone(), + bootloader, + is_insecure, + ) + } + + BootSetupType::Upgrade((_, host)) => { + let sysroot = Utf8PathBuf::from("/sysroot"); + let sysroot_parent = get_sysroot_parent_dev()?; + let bootloader = host.require_composefs_booted()?.bootloader.clone(); + + ( + sysroot, + get_esp_partition(&sysroot_parent)?.0, + bootloader, + false, + ) + } + }; + + 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 mut boot_label = String::new(); + + for entry in entries { + match entry { + ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"), + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => { + tracing::debug!("Skipping vmlinuz in /usr/lib/modules") + } + + ComposefsBootEntry::Type2(entry) => { + let ret = write_pe_to_esp( + &repo, + &entry.file, + &entry.file_path, + entry.pe_type, + &id.to_hex(), + is_insecure_from_opts, + &mounted_efi, + )?; + + if let Some(label) = ret { + boot_label = label; + } + } + }; + } + + Command::new("umount") + .arg(&mounted_efi) + .log_debug() + .run_inherited_with_cmd_context() + .context("Unmounting ESP")?; + + match bootloader { + Bootloader::Grub => { + write_grub_uki_menuentry(root_path, &setup_type, &boot_label, id, &esp_device)? + } + + Bootloader::Systemd => { + // No-op for now, but later we want to have .conf files so we can control the order of + // entries. + } + }; + + 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.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, + entries, + )?, + }; + + 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/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs new file mode 100644 index 000000000..22ee0ea96 --- /dev/null +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -0,0 +1,129 @@ +use std::path::Path; + +use crate::bootc_composefs::boot::{get_esp_partition, get_sysroot_parent_dev, BootType}; +use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}; +use crate::spec::Bootloader; +use crate::{ + bootc_composefs::status::composefs_deployment_status, composefs_consts::STATE_DIR_ABS, +}; +use anyhow::{Context, Result}; +use bootc_initramfs_setup::{mount_composefs_image, open_dir}; +use bootc_mount::tempmount::TempMount; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use cap_std_ext::dirext::CapStdExtDirExt; +use etc_merge::{compute_diff, merge, traverse_etc}; +use rustix::fs::{fsync, renameat, CWD}; +use rustix::path::Arg; + +use fn_error_context::context; + +pub(crate) async fn composefs_native_finalize() -> Result<()> { + let host = composefs_deployment_status().await?; + + let booted_composefs = host.require_composefs_booted()?; + + let Some(staged_depl) = host.status.staged.as_ref() else { + tracing::debug!("No staged deployment found"); + return Ok(()); + }; + + let staged_composefs = staged_depl.composefs.as_ref().ok_or(anyhow::anyhow!( + "Staged deployment is not a composefs deployment" + ))?; + + // Mount the booted EROFS image to get pristine etc + let sysroot = open_dir(CWD, "/sysroot")?; + let composefs_fd = mount_composefs_image(&sysroot, &booted_composefs.verity, false)?; + + let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; + + // Perform the /etc merge + let pristine_etc = + Dir::open_ambient_dir(erofs_tmp_mnt.dir.path().join("etc"), ambient_authority())?; + let current_etc = Dir::open_ambient_dir("/etc", ambient_authority())?; + + let new_etc_path = Path::new(STATE_DIR_ABS) + .join(&staged_composefs.verity) + .join("etc"); + + let new_etc = Dir::open_ambient_dir(new_etc_path, ambient_authority())?; + + let (pristine_files, current_files, new_files) = + traverse_etc(&pristine_etc, ¤t_etc, &new_etc)?; + + let diff = compute_diff(&pristine_files, ¤t_files)?; + merge(¤t_etc, ¤t_files, &new_etc, &new_files, diff)?; + + // Unmount EROFS + drop(erofs_tmp_mnt); + + let sysroot_parent = get_sysroot_parent_dev()?; + // NOTE: Assumption here that ESP will always be present + let (esp_part, ..) = get_esp_partition(&sysroot_parent)?; + + let esp_mount = TempMount::mount_dev(&esp_part)?; + let boot_dir = Dir::open_ambient_dir("/sysroot/boot", ambient_authority()) + .context("Opening sysroot/boot")?; + + // NOTE: Assuming here we won't have two bootloaders at the same time + match booted_composefs.bootloader { + Bootloader::Grub => match staged_composefs.boot_type { + BootType::Bls => { + let entries_dir = boot_dir.open_dir("loader")?; + rename_exchange_bls_entries(&entries_dir)?; + } + BootType::Uki => finalize_staged_grub_uki(&esp_mount.fd, &boot_dir)?, + }, + + Bootloader::Systemd => match staged_composefs.boot_type { + BootType::Bls => { + let entries_dir = esp_mount.fd.open_dir("loader")?; + rename_exchange_bls_entries(&entries_dir)?; + } + BootType::Uki => rename_staged_uki_entries(&esp_mount.fd)?, + }, + }; + + Ok(()) +} + +#[context("Grub: Finalizing staged UKI")] +fn finalize_staged_grub_uki(esp_mount: &Dir, boot_fd: &Dir) -> Result<()> { + rename_staged_uki_entries(esp_mount)?; + + let entries_dir = boot_fd.open_dir("grub2")?; + rename_exchange_user_cfg(&entries_dir)?; + + let entries_dir = entries_dir.reopen_as_ownedfd()?; + fsync(entries_dir).context("fsync")?; + + Ok(()) +} + +#[context("Renaming staged UKI entries")] +fn rename_staged_uki_entries(esp_mount: &Dir) -> Result<()> { + for entry in esp_mount.entries()? { + let entry = entry?; + + let filename = entry.file_name(); + let filename = filename.as_str()?; + + if !filename.ends_with(".staged") { + continue; + } + + renameat( + &esp_mount, + filename, + &esp_mount, + // SAFETY: We won't reach here if not for the above condition + filename.strip_suffix(".staged").unwrap(), + ) + .context("Renaming {filename}")?; + } + + let esp_mount = esp_mount.reopen_as_ownedfd()?; + fsync(esp_mount).context("fsync")?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs new file mode 100644 index 000000000..c19dbfb77 --- /dev/null +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod boot; +pub(crate) mod finalize; +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..0ea23b7cd --- /dev/null +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -0,0 +1,198 @@ +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, STAGED_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_STAGED, + }, + spec::BootOrder, +}; + +pub(crate) fn rename_exchange_user_cfg(entries_dir: &Dir) -> Result<()> { + tracing::debug!("Atomically exchanging {USER_CFG_STAGED} and {USER_CFG}"); + renameat_with( + &entries_dir, + USER_CFG_STAGED, + &entries_dir, + USER_CFG, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {USER_CFG_STAGED}"); + rustix::fs::unlinkat(&entries_dir, USER_CFG_STAGED, AtFlags::empty()).context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + let entries_dir = entries_dir + .reopen_as_ownedfd() + .context(format!("Reopening entries dir as owned fd"))?; + + fsync(entries_dir).context(format!("fsync entries dir"))?; + + Ok(()) +} + +pub(crate) fn rename_exchange_bls_entries(entries_dir: &Dir) -> Result<()> { + tracing::debug!("Atomically exchanging {STAGED_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}"); + renameat_with( + &entries_dir, + STAGED_BOOT_LOADER_ENTRIES, + &entries_dir, + BOOT_LOADER_ENTRIES, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {STAGED_BOOT_LOADER_ENTRIES}"); + rustix::fs::unlinkat(&entries_dir, STAGED_BOOT_LOADER_ENTRIES, AtFlags::REMOVEDIR) + .context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + let entries_dir = entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening /sysroot/boot/loader as owned fd"))?; + + fsync(entries_dir).context("fsync")?; + + 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_STAGED, buffer) + .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; + + rename_exchange_user_cfg(&entries_dir) +} + +#[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/{STAGED_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")?; + + rename_exchange_bls_entries(&dir) +} + +#[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 new file mode 100644 index 000000000..ac12ed541 --- /dev/null +++ b/crates/lib/src/bootc_composefs/state.rs @@ -0,0 +1,165 @@ +use std::os::unix::fs::symlink; +use std::{fs::create_dir_all, process::Command}; + +use anyhow::{Context, Result}; +use bootc_kernel_cmdline::utf8::Cmdline; +use bootc_mount::tempmount::TempMount; +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}, + 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(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") + }; + let opts = Cmdline::from(opts); + + if opts.iter().any(|v| v == booted) { + 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( + sysroot_path: &Utf8PathBuf, + erofs_id: &String, + state_path: &Utf8PathBuf, +) -> Result<()> { + let sysroot_fd = open( + sysroot_path.as_std_path(), + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .context("Opening sysroot")?; + + let composefs_fd = bootc_initramfs_setup::mount_composefs_image(&sysroot_fd, &erofs_id, false)?; + + let tempdir = TempMount::mount_fd(composefs_fd)?; + + // TODO: Replace this with a function to cap_std_ext + let cp_ret = Command::new("cp") + .args([ + "-a", + &format!("{}/etc/.", tempdir.dir.path().as_str()?), + &format!("{state_path}/etc/."), + ]) + .run_capture_stderr(); + + 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..f99009a7b --- /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::utf8::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(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..bebb95399 --- /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.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, entries)? + } + }; + + 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..823a50bed --- /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.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, entries)? + } + }; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + imgref, + true, + boot_type, + boot_digest, + )?; + + Ok(()) +} diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index aa07bfe80..0f02198aa 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -16,20 +16,25 @@ pub(crate) fn install_via_bootupd( device: &PartitionTable, rootfs: &Utf8Path, configopts: &crate::install::InstallConfigOpts, - deployment_path: &str, + deployment_path: Option<&str>, ) -> Result<()> { let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv"); // bootc defaults to only targeting the platform boot method. let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]); - let srcroot = rootfs.join(deployment_path); + let abs_deployment_path = deployment_path.map(|v| rootfs.join(v)); + let src_root_arg = if let Some(p) = abs_deployment_path.as_deref() { + vec!["--src-root", p.as_str()] + } else { + vec![] + }; let devpath = device.path(); println!("Installing bootloader via bootupd"); Command::new("bootupctl") .args(["backend", "install", "--write-uuid"]) .args(verbose) .args(bootupd_opts.iter().copied().flatten()) - .args(["--src-root", srcroot.as_str()]) + .args(src_root_arg) .args(["--device", devpath.as_str(), rootfs.as_str()]) .log_debug() .run_inherited_with_cmd_context() diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 93be94c2c..e4b2371e7 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -29,6 +29,11 @@ use ostree_ext::sysroot::SysrootLock; use schemars::schema_for; use serde::{Deserialize, Serialize}; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::{ + finalize::composefs_native_finalize, 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}; @@ -646,6 +651,8 @@ pub(crate) enum Opt { #[clap(subcommand)] #[clap(hide = true)] Internals(InternalsOpts), + #[cfg(feature = "composefs-backend")] + ComposefsFinalizeStaged, } /// Ensure we've entered a mount namespace, so that we can remount @@ -968,9 +975,7 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { Ok(()) } -/// Implementation of the `bootc switch` CLI command. -#[context("Switching")] -async fn 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, @@ -979,6 +984,15 @@ async fn switch(opts: SwitchOpts) -> Result<()> { let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy); let target = ostree_container::OstreeImageReference { sigverify, imgref }; let target = ImageReference::from(target); + + return Ok(target); +} + +/// Implementation of the `bootc switch` CLI command. +#[context("Switching")] +async fn switch(opts: SwitchOpts) -> Result<()> { + let target = imgref_for_switch(&opts)?; + let prog: ProgressWriter = opts.progress.try_into()?; // If we're doing an in-place mutation, we shortcut most of the rest of the work here @@ -1069,7 +1083,7 @@ async fn switch(opts: SwitchOpts) -> Result<()> { /// Implementation of the `bootc rollback` CLI command. #[context("Rollback")] -async fn rollback(opts: RollbackOpts) -> Result<()> { +async fn rollback(opts: &RollbackOpts) -> Result<()> { let sysroot = &get_storage().await?; let ostree = sysroot.get_ostree()?; crate::deploy::rollback(sysroot).await?; @@ -1086,10 +1100,6 @@ async fn rollback(opts: RollbackOpts) -> Result<()> { )?; } - if opts.apply { - crate::reboot::reboot()?; - } - Ok(()) } @@ -1233,9 +1243,45 @@ impl Opt { async fn run_from_opt(opt: Opt) -> Result<()> { let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; match opt { - Opt::Upgrade(opts) => upgrade(opts).await, - Opt::Switch(opts) => switch(opts).await, - Opt::Rollback(opts) => rollback(opts).await, + 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::Edit(opts) => edit(opts).await, Opt::UsrOverlay => usroverlay().await, Opt::Container(opts) => match opts { @@ -1375,8 +1421,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { FsverityOpts::Enable { path } => { let fd = std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?; - // Note this is not robust to forks, we're not using the _maybe_copy variant - fsverity::enable_verity_with_retry::(&fd)?; + fsverity::enable_verity_raw::(&fd)?; Ok(()) } }, @@ -1477,6 +1522,9 @@ async fn run_from_opt(opt: Opt) -> Result<()> { Ok(()) } }, + + #[cfg(feature = "composefs-backend")] + Opt::ComposefsFinalizeStaged => composefs_native_finalize().await, } } diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs new file mode 100644 index 000000000..3c18287a1 --- /dev/null +++ b/crates/lib/src/composefs_consts.rs @@ -0,0 +1,33 @@ +#![allow(dead_code)] + +/// composefs= paramter in kernel cmdline +pub const COMPOSEFS_CMDLINE: &str = "composefs"; + +/// Directory to store transient state, such as staged deployemnts etc +pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; +/// File created in /run/composefs to record a staged-deployment +pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_FNAME: &str = "staged-deployment"; + +/// Absolute path to composefs-native state directory +pub(crate) const STATE_DIR_ABS: &str = "/sysroot/state/deploy"; +/// Relative path to composefs-native state directory. Relative to /sysroot +pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; +/// Relative path to the shared 'var' directory. Relative to /sysroot +pub(crate) const SHARED_VAR_PATH: &str = "state/os/default/var"; + +/// Section in .origin file to store boot related metadata +pub(crate) const ORIGIN_KEY_BOOT: &str = "boot"; +/// Whether the deployment was booted with BLS or UKI +pub(crate) const ORIGIN_KEY_BOOT_TYPE: &str = "boot_type"; +/// Key to store the SHA256 sum of vmlinuz + initrd for a deployment +pub(crate) const ORIGIN_KEY_BOOT_DIGEST: &str = "digest"; + +/// Filename for `loader/entries` +pub(crate) const BOOT_LOADER_ENTRIES: &str = "entries"; +/// Filename for staged boot loader entries +pub(crate) const STAGED_BOOT_LOADER_ENTRIES: &str = "entries.staged"; + +/// Filename for grub user config +pub(crate) const USER_CFG: &str = "user.cfg"; +/// Filename for staged grub user config +pub(crate) const USER_CFG_STAGED: &str = "user.cfg.staged"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 4a71bc409..31e0bec2c 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -53,17 +53,21 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "install-to-disk")] use self::baseline::InstallBlockDeviceOpts; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::{boot::setup_composefs_boot, repo::initialize_composefs_repository}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; use crate::containerenv::ContainerExecutionInfo; use crate::deploy::{prepare_for_pull, pull_from_prepared, PreparedImportMeta, PreparedPullResult}; use crate::lsm; use crate::progress_jsonl::ProgressWriter; -use crate::spec::ImageReference; +use crate::spec::{Bootloader, ImageReference}; use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; -use bootc_kernel_cmdline::{bytes, utf8}; +use bootc_kernel_cmdline::{bytes, utf8, INITRD_ARG_PREFIX, ROOTFLAGS}; use bootc_mount::Filesystem; +#[cfg(feature = "composefs-backend")] +use composefs::fsverity::FsVerityHashValue; /// The toplevel boot directory const BOOT: &str = "boot"; @@ -81,12 +85,12 @@ const OSTREE_COMPOSEFS_SUPER: &str = ".ostree.cfs"; /// The mount path for selinux const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi -const EFIVARFS: &str = "/sys/firmware/efi/efivars"; +pub(crate) const EFIVARFS: &str = "/sys/firmware/efi/efivars"; pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); -/// This is used by dracut. -pub const INITRD_ARG_PREFIX: &str = "rd."; -/// The kernel argument for configuring the rootfs flags. -pub const ROOTFLAGS: &str = "rootflags"; +#[cfg(any(feature = "composefs-backend", feature = "install-to-disk"))] +pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; +#[cfg(any(feature = "composefs-backend", feature = "install-to-disk"))] +pub(crate) const DPS_UUID: &str = "6523f8ae-3eb1-4e2a-a05a-18b695ae656f"; const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ // Default to avoiding grub2-mkconfig etc. @@ -98,7 +102,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 { @@ -193,7 +197,7 @@ pub(crate) struct InstallConfigOpts { /// /// Example: --karg=nosmt --karg=console=ttyS0,114800n8 #[clap(long)] - karg: Option>, + pub(crate) karg: Option>, /// The path to an `authorized_keys` that will be injected into the `root` account. /// @@ -225,6 +229,17 @@ pub(crate) struct InstallConfigOpts { pub(crate) stateroot: Option, } +#[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallComposefsOpts { + #[clap(long, default_value_t)] + #[serde(default)] + pub(crate) insecure: bool, + + #[clap(long, default_value_t)] + #[serde(default)] + pub(crate) bootloader: Bootloader, +} + #[cfg(feature = "install-to-disk")] #[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallToDiskOpts { @@ -248,6 +263,16 @@ pub(crate) struct InstallToDiskOpts { #[clap(long)] #[serde(default)] pub(crate) via_loopback: bool, + + #[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, } #[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -370,6 +395,7 @@ pub(crate) struct SourceInfo { } // Shared read-only global state +#[derive(Debug)] pub(crate) struct State { pub(crate) source: SourceInfo, /// Force SELinux off in target system @@ -387,6 +413,10 @@ pub(crate) struct State { /// The root filesystem of the running container pub(crate) container_root: Dir, pub(crate) tempdir: TempDir, + + // If Some, then --composefs_native is passed + #[cfg(feature = "composefs-backend")] + pub(crate) composefs_options: Option, } impl State { @@ -513,6 +543,20 @@ impl FromStr for MountSpec { } } +#[cfg(all(feature = "install-to-disk", feature = "composefs-backend"))] +impl InstallToDiskOpts { + pub(crate) fn validate(&self) -> Result<()> { + if !self.composefs_native { + // Reject using --insecure without --composefs + if self.composefs_opts.insecure != false { + anyhow::bail!("--insecure must not be provided without --composefs"); + } + } + + Ok(()) + } +} + impl SourceInfo { // Inspect container information and convert it to an ostree image reference // that pulls from containers-storage. @@ -928,17 +972,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> { @@ -949,7 +993,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() } @@ -1158,6 +1202,7 @@ async fn prepare_install( config_opts: InstallConfigOpts, source_opts: InstallSourceOpts, target_opts: InstallTargetOpts, + _composefs_opts: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1302,6 +1347,8 @@ async fn prepare_install( container_root: rootfs, tempdir, host_is_container, + #[cfg(feature = "composefs-backend")] + composefs_options: _composefs_opts, }); Ok(state) @@ -1338,7 +1385,7 @@ async fn install_with_sysroot( &rootfs.device_info, &rootfs.physical_root_path, &state.config_opts, - &deployment_path.as_str(), + Some(&deployment_path.as_str()), )?; } tracing::debug!("Installed bootloader"); @@ -1400,29 +1447,7 @@ impl BoundImages { } } -async fn install_to_filesystem_impl( - state: &State, - rootfs: &mut RootSetup, - cleanup: Cleanup, -) -> Result<()> { - if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { - rootfs.kargs.push("selinux=0".to_string()); - } - // Drop exclusive ownership since we're done with mutation - let rootfs = &*rootfs; - - match &rootfs.device_info.label { - bootc_blockdev::PartitionType::Dos => crate::utils::medium_visibility_warning( - "Installing to `dos` format partitions is not recommended", - ), - bootc_blockdev::PartitionType::Gpt => { - // The only thing we should be using in general - } - bootc_blockdev::PartitionType::Unknown(o) => { - crate::utils::medium_visibility_warning(&format!("Unknown partition label {o}")) - } - } - +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()? @@ -1451,7 +1476,7 @@ async fn install_to_filesystem_impl( 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"")?; + sysroot_dir.atomic_write(format!("etc/{}", DESTRUCTIVE_CLEANUP), b"")?; } // We must drop the sysroot here in order to close any open file @@ -1461,6 +1486,46 @@ async fn install_to_filesystem_impl( // Run this on every install as the penultimate step install_finalize(&rootfs.physical_root_path).await?; + Ok(()) +} + +async fn install_to_filesystem_impl( + state: &State, + rootfs: &mut RootSetup, + cleanup: Cleanup, +) -> Result<()> { + if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { + rootfs.kargs.push("selinux=0".to_string()); + } + // Drop exclusive ownership since we're done with mutation + let rootfs = &*rootfs; + + match &rootfs.device_info.label { + bootc_blockdev::PartitionType::Dos => crate::utils::medium_visibility_warning( + "Installing to `dos` format partitions is not recommended", + ), + bootc_blockdev::PartitionType::Gpt => { + // The only thing we should be using in general + } + bootc_blockdev::PartitionType::Unknown(o) => { + crate::utils::medium_visibility_warning(&format!("Unknown partition label {o}")) + } + } + + #[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: {}, verity: {}", hex::encode(id), verity.to_hex()); + setup_composefs_boot(rootfs, state, &hex::encode(id))?; + } else { + 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")); @@ -1480,6 +1545,9 @@ 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 const INSTALL_DISK_JOURNAL_ID: &str = "8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2"; let source_image = opts @@ -1521,7 +1589,24 @@ 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); } - let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts).await?; + + #[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, + composefs_arg, + ) + .await?; // This is all blocking stuff let (mut rootfs, loopback) = { @@ -1752,7 +1837,7 @@ pub(crate) async fn install_to_filesystem( // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. // IMPORTANT: In practice, we should only be gathering information before this point, // IMPORTANT: and not performing any mutations at all. - let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts).await?; + let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts, None).await?; // And the last bit of state here is the fsopts, which we also destructure now. let mut fsopts = opts.filesystem_opts; diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 1cd48c48f..e5f30ca1c 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -42,8 +42,6 @@ pub(crate) const LINUX_PARTTYPE: &str = "0FC63DAF-8483-4772-8E79-3D69D8477DE4"; pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; #[cfg(feature = "install-to-disk")] pub(crate) const PREPBOOT_LABEL: &str = "PowerPC-PReP-boot"; -#[cfg(feature = "install-to-disk")] -pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; #[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -106,10 +104,15 @@ fn mkfs<'a>( label: &str, wipe: bool, opts: impl IntoIterator, + dps_uuid: Option, ) -> Result { let devinfo = bootc_blockdev::list_dev(dev.into())?; let size = ostree_ext::glib::format_size(devinfo.size); - let u = uuid::Uuid::new_v4(); + let u = if let Some(u) = dps_uuid { + u + } else { + uuid::Uuid::new_v4() + }; let mut t = Task::new( &format!("Creating {label} filesystem ({fs}) on device {dev} (size={size})"), format!("mkfs.{fs}"), @@ -275,7 +278,7 @@ pub(crate) fn install_create_rootfs( } let esp_partno = if super::ARCH_USES_EFI { - let esp_guid = ESP_GUID; + let esp_guid = crate::install::ESP_GUID; partno += 1; writeln!( &mut partitioning_buf, @@ -383,6 +386,7 @@ pub(crate) fn install_create_rootfs( "boot", opts.wipe, [], + None, ) .context("Initializing /boot")?, ) @@ -403,6 +407,8 @@ pub(crate) fn install_create_rootfs( "root", opts.wipe, mkfs_options.iter().copied(), + // TODO: Add cli option for this + Some(uuid::uuid!(crate::install::DPS_UUID)), )?; let rootarg = format!("root=UUID={root_uuid}"); let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); @@ -418,6 +424,7 @@ pub(crate) fn install_create_rootfs( .flatten() .chain([rootarg, RW_KARG.to_string()].into_iter()) .chain(bootarg) + .chain(state.config_opts.karg.clone().into_iter().flatten()) .collect::>(); bootc_mount::mount(&rootdev, &physical_root_path)?; diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index e465a84da..297eca560 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -4,20 +4,27 @@ //! 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; mod boundimage; mod cfsctl; pub mod cli; +mod composefs_consts; +mod containerenv; pub(crate) mod deploy; pub(crate) mod fsck; pub(crate) mod generator; mod glyph; mod image; +mod install; pub(crate) mod journal; mod k8sapitypes; mod lints; mod lsm; pub(crate) mod metadata; +mod parsers; mod podman; mod podstorage; mod progress_jsonl; @@ -31,13 +38,6 @@ mod utils; #[cfg(feature = "docgen")] mod cli_json; -mod bootloader; -mod containerenv; -mod install; - -#[cfg(feature = "composefs-backend")] -#[allow(dead_code)] -pub(crate) mod parsers; #[cfg(feature = "rhsm")] mod rhsm; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 2f9ff34f6..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; @@ -12,7 +14,7 @@ use uapi_version::Version; /// The boot loader should present the available boot menu entries to the user in a sorted list. /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, Default)] #[non_exhaustive] pub(crate) struct BLSConfig { /// The title of the boot entry, to be displayed in the boot menu. @@ -103,6 +105,41 @@ impl BLSConfig { pub(crate) fn version(&self) -> Version { Version::from(&self.version) } + + pub(crate) fn with_title(&mut self, new_val: String) -> &mut Self { + self.title = Some(new_val); + self + } + pub(crate) fn with_version(&mut self, new_val: String) -> &mut Self { + self.version = new_val; + self + } + pub(crate) fn with_linux(&mut self, new_val: String) -> &mut Self { + self.linux = new_val; + self + } + pub(crate) fn with_initrd(&mut self, new_val: Vec) -> &mut Self { + self.initrd = new_val; + self + } + pub(crate) fn with_options(&mut self, new_val: String) -> &mut Self { + self.options = Some(new_val); + self + } + #[allow(dead_code)] + pub(crate) fn with_machine_id(&mut self, new_val: String) -> &mut Self { + self.machine_id = Some(new_val); + self + } + pub(crate) fn with_sort_key(&mut self, new_val: String) -> &mut Self { + self.sort_key = Some(new_val); + self + } + #[allow(dead_code)] + pub(crate) fn with_extra(&mut self, new_val: HashMap) -> &mut Self { + self.extra = new_val; + self + } } pub(crate) fn parse_bls_config(input: &str) -> Result { diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 146903ef9..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::{ @@ -14,13 +16,15 @@ use nom::{ #[derive(Debug, PartialEq, Eq)] pub(crate) struct MenuentryBody<'a> { /// Kernel modules to load - insmod: Vec<&'a str>, + pub(crate) insmod: Vec<&'a str>, /// Chainloader path (optional) - chainloader: Option<&'a str>, + pub(crate) chainloader: String, /// Search command (optional) - search: Option<&'a str>, + pub(crate) search: &'a str, + /// The version + pub(crate) version: u8, /// Additional commands - extra: Vec<(&'a str, &'a str)>, + pub(crate) extra: Vec<(&'a str, &'a str)>, } impl<'a> Display for MenuentryBody<'a> { @@ -29,13 +33,8 @@ impl<'a> Display for MenuentryBody<'a> { writeln!(f, "insmod {}", insmod)?; } - if let Some(search) = self.search { - writeln!(f, "search {}", search)?; - } - - if let Some(chainloader) = self.chainloader { - writeln!(f, "chainloader {}", chainloader)?; - } + writeln!(f, "search {}", self.search)?; + writeln!(f, "chainloader {}", self.chainloader)?; for (k, v) in &self.extra { writeln!(f, "{k} {v}")?; @@ -49,17 +48,17 @@ impl<'a> From> for MenuentryBody<'a> { fn from(vec: Vec<(&'a str, &'a str)>) -> Self { let mut entry = Self { insmod: vec![], - chainloader: None, - search: None, + chainloader: "".into(), + search: "", + version: 0, extra: vec![], }; for (key, value) in vec { match key { "insmod" => entry.insmod.push(value), - "chainloader" => entry.chainloader = Some(value), - "search" => entry.search = Some(value), - // Skip 'set' commands as they are typically variable assignments + "chainloader" => entry.chainloader = value.into(), + "search" => entry.search = value, "set" => {} _ => entry.extra.push((key, value)), } @@ -73,7 +72,7 @@ impl<'a> From> for MenuentryBody<'a> { #[derive(Debug, PartialEq, Eq)] pub(crate) struct MenuEntry<'a> { /// Display title (supports escaped quotes) - pub(crate) title: &'a str, + pub(crate) title: String, /// Commands within the menuentry block pub(crate) body: MenuentryBody<'a>, } @@ -86,6 +85,22 @@ impl<'a> Display for MenuEntry<'a> { } } +impl<'a> MenuEntry<'a> { + #[allow(dead_code)] + pub(crate) fn new(boot_label: &str, uki_id: &str) -> Self { + Self { + title: format!("{boot_label}: ({uki_id})"), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: format!("/EFI/Linux/{uki_id}.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + } + } +} + /// Parser that takes content until balanced brackets, handling nested brackets and escapes. fn take_until_balanced_allow_nested( opening_bracket: char, @@ -180,7 +195,7 @@ fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> { Ok(( input, MenuEntry { - title, + title: title.to_string(), body: MenuentryBody::from(map), }, )) @@ -272,20 +287,22 @@ mod test { let expected = vec![ MenuEntry { - title: "Fedora 42: (Verity-42)", + title: "Fedora 42: (Verity-42)".into(), body: MenuentryBody { insmod: vec!["fat", "chain"], - search: Some("--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\""), - chainloader: Some("/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + version: 0, extra: vec![], }, }, MenuEntry { - title: "Fedora 43: (Verity-43)", + title: "Fedora 43: (Verity-43)".into(), body: MenuentryBody { insmod: vec!["fat", "chain"], - search: Some("--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\""), - chainloader: Some("/EFI/Linux/uki.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + chainloader: "/EFI/Linux/uki.efi".into(), + version: 0, extra: vec![ ("extra_field1", "this is extra"), ("extra_field2", "this is also extra") @@ -312,7 +329,7 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].title, "Title with \\\"escaped quotes\\\" inside"); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/test.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); } #[test] @@ -361,8 +378,8 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].title, "Minimal Entry"); assert_eq!(result[0].body.insmod.len(), 0); - assert_eq!(result[0].body.chainloader, None); - assert_eq!(result[0].body.search, None); + assert_eq!(result[0].body.chainloader, ""); + assert_eq!(result[0].body.search, ""); assert_eq!(result[0].body.extra.len(), 0); } @@ -380,8 +397,8 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].body.insmod, vec!["fat", "chain", "ext2"]); - assert_eq!(result[0].body.chainloader, None); - assert_eq!(result[0].body.search, None); + assert_eq!(result[0].body.chainloader, ""); + assert_eq!(result[0].body.search, ""); } #[test] @@ -399,7 +416,7 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].body.insmod, vec!["fat"]); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/test.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); // set commands should be ignored assert!(!result[0].body.extra.iter().any(|(k, _)| k == &"set")); } @@ -421,7 +438,7 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].title, "Nested Braces"); assert_eq!(result[0].body.insmod, vec!["fat"]); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/test.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); // The if/fi block should be captured as extra commands assert!(result[0].body.extra.iter().any(|(k, _)| k == &"if")); } @@ -500,12 +517,9 @@ mod test { assert_eq!(result.len(), 2); assert_eq!(result[0].title, "First Entry"); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/first.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/first.efi"); assert_eq!(result[1].title, "Second Entry"); - assert_eq!(result[1].body.chainloader, Some("/EFI/Linux/second.efi")); - assert_eq!( - result[1].body.search, - Some("--set=root --fs-uuid \"some-uuid\"") - ); + assert_eq!(result[1].body.chainloader, "/EFI/Linux/second.efi"); + assert_eq!(result[1].body.search, "--set=root --fs-uuid \"some-uuid\""); } } diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 4034212fc..b59877e43 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -1,6 +1,7 @@ //! The definition for host system state. use std::fmt::Display; +use std::str::FromStr; use anyhow::Result; use ostree_ext::container::Transport; @@ -10,6 +11,8 @@ use ostree_ext::{container::OstreeImageReference, oci_spec}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::boot::BootType; use crate::{k8sapitypes, status::Slot}; const API_VERSION: &str = "org.containers.bootc/v1"; @@ -160,6 +163,52 @@ pub struct BootEntryOstree { pub deploy_serial: u32, } +/// Bootloader type to determine whether system was booted via Grub or Systemd +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub enum Bootloader { + /// Booted via Grub + #[default] + Grub, + /// Booted via Systemd + Systemd, +} + +impl Display for Bootloader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let string = match self { + Bootloader::Grub => "grub", + Bootloader::Systemd => "systemd", + }; + + write!(f, "{}", string) + } +} + +impl FromStr for Bootloader { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "grub" => Ok(Self::Grub), + "systemd" => Ok(Self::Systemd), + unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")), + } + } +} + +/// 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, + /// Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry + pub boot_type: BootType, + /// Whether we boot using systemd or grub + pub bootloader: Bootloader, +} + /// A bootable entry #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -180,6 +229,9 @@ pub struct BootEntry { pub store: Option, /// 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, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -250,6 +302,20 @@ impl Host { } } } + + #[cfg(feature = "composefs-backend")] + pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> { + let cfs = self + .status + .booted + .as_ref() + .ok_or(anyhow::anyhow!("Could not find booted deployment"))? + .composefs + .as_ref() + .ok_or(anyhow::anyhow!("Could not find booted image"))?; + + Ok(cfs) + } } impl Default for Host { @@ -520,6 +586,8 @@ 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 4b0be123f..22741e2b2 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -15,9 +15,12 @@ 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::ostree; use ostree_ext::sysroot::SysrootLock; +use ostree_ext::ostree; + +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::status::{composefs_booted, composefs_deployment_status}; use crate::cli::OutputFormat; use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; @@ -207,6 +210,8 @@ fn boot_entry_from_deployment( deploy_serial: deployment.deployserial().try_into().unwrap(), stateroot: deployment.stateroot().into(), }), + #[cfg(feature = "composefs-backend")] + composefs: None, }; Ok(r) } @@ -335,6 +340,38 @@ pub(crate) fn get_status( Ok((deployments, host)) } +#[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() + }; + + Ok(host) +} + +#[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) +} + /// Implementation of the `bootc status` CLI command. #[context("Status")] pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { @@ -343,15 +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()? { - Default::default() - } else { - 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 - }; + 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. @@ -485,6 +514,13 @@ fn human_render_slot( let digest = &image.image_digest; 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)?; + } + // Format the timestamp without nanoseconds since those are just irrelevant noise for human // consumption - that time scale should basically never matter for container builds. let timestamp = image @@ -585,6 +621,28 @@ fn human_render_slot_ostree( Ok(()) } +/// 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, + entry: &crate::spec::BootEntry, + erofs_verity: &str, +) -> Result<()> { + // TODO consider rendering more ostree stuff here like rpm-ostree status does + let prefix = match slot { + Slot::Staged => " Staged composefs".into(), + Slot::Booted => format!("{} Booted composefs", crate::glyph::Glyph::BlackCircle), + Slot::Rollback => " Rollback composefs".into(), + }; + let prefix_len = prefix.len(); + writeln!(out, "{prefix}")?; + write_row_name(&mut out, "Commit", prefix_len)?; + writeln!(out, "{erofs_verity}")?; + tracing::debug!("pinned={}", entry.pinned); + Ok(()) +} + fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) -> Result<()> { let mut first = true; for (slot_name, status) in [ @@ -598,6 +656,25 @@ 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() { + human_render_slot_ostree( + &mut out, + Some(slot_name), + host_status, + &ostree.checksum, + verbose, + )?; + } else if let Some(composefs) = &host_status.composefs { + human_render_slot_composefs(&mut out, slot_name, host_status, &composefs.verity)?; + } 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() { diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index e2d6a8024..712a1fc1f 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::io::Write; use std::os::fd::BorrowedFd; +#[cfg(feature = "composefs-backend")] +use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -186,6 +188,29 @@ pub(crate) fn digested_pullspec(image: &str, digest: &str) -> String { format!("{image}@{digest}") } +/// 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"); + } + + let from = from.components().collect::>(); + let to = to.components().collect::>(); + + let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count(); + + let up = std::iter::repeat(Component::ParentDir).take(from.len() - common); + + let mut final_path = PathBuf::new(); + final_path.extend(up); + final_path.extend(&to[common..]); + + return Ok(final_path); +} + #[cfg(test)] mod tests { use super::*; @@ -223,4 +248,22 @@ mod tests { SignatureSource::ContainerPolicyAllowInsecure ); } + + #[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"); + + assert_eq!( + path_relative_to(from, to).unwrap(), + PathBuf::from("../../os/default/var") + ); + assert_eq!( + path_relative_to(&Path::new("state/deploy"), to) + .unwrap_err() + .to_string(), + "Paths must be absolute" + ); + } } diff --git a/crates/mount/Cargo.toml b/crates/mount/Cargo.toml index 8e29bb5f2..a8a7475a3 100644 --- a/crates/mount/Cargo.toml +++ b/crates/mount/Cargo.toml @@ -20,6 +20,8 @@ libc = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } +tempfile = { workspace = true } +cap-std-ext = { workspace = true } [dev-dependencies] indoc = { workspace = true } diff --git a/crates/mount/src/mount.rs b/crates/mount/src/mount.rs index 71133d9ed..1e5d56e47 100644 --- a/crates/mount/src/mount.rs +++ b/crates/mount/src/mount.rs @@ -22,6 +22,8 @@ use rustix::{ }; use serde::Deserialize; +pub mod tempmount; + /// Well known identifier for pid 1 pub const PID1: Pid = const { match Pid::from_raw(1) { diff --git a/crates/mount/src/tempmount.rs b/crates/mount/src/tempmount.rs new file mode 100644 index 000000000..56a3a6493 --- /dev/null +++ b/crates/mount/src/tempmount.rs @@ -0,0 +1,76 @@ +use std::os::fd::AsFd; + +use anyhow::{Context, Result}; + +use camino::Utf8Path; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use fn_error_context::context; +use rustix::mount::{move_mount, unmount, MoveMountFlags, UnmountFlags}; + +pub struct TempMount { + pub dir: tempfile::TempDir, + pub fd: Dir, +} + +impl TempMount { + /// Mount device/partition on a tempdir which will be automatically unmounted on drop + #[context("Mounting {dev}")] + pub fn mount_dev(dev: &str) -> Result { + let tempdir = tempfile::TempDir::new()?; + + let utf8path = Utf8Path::from_path(tempdir.path()) + .ok_or(anyhow::anyhow!("Failed to convert path to UTF-8 Path"))?; + + crate::mount(dev, utf8path)?; + + let fd = Dir::open_ambient_dir(tempdir.path(), ambient_authority()) + .with_context(|| format!("Opening {:?}", tempdir.path())); + + let fd = match fd { + Ok(fd) => fd, + Err(e) => { + unmount(tempdir.path(), UnmountFlags::DETACH)?; + Err(e)? + } + }; + + Ok(Self { dir: tempdir, fd }) + } + + /// Mount and fd acquired with `open_tree` like syscall + #[context("Mounting fd")] + pub fn mount_fd(mnt_fd: impl AsFd) -> Result { + let tempdir = tempfile::TempDir::new()?; + + move_mount( + mnt_fd.as_fd(), + "", + rustix::fs::CWD, + tempdir.path(), + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .context("move_mount")?; + + let fd = Dir::open_ambient_dir(tempdir.path(), ambient_authority()) + .with_context(|| format!("Opening {:?}", tempdir.path())); + + let fd = match fd { + Ok(fd) => fd, + Err(e) => { + unmount(tempdir.path(), UnmountFlags::DETACH)?; + Err(e)? + } + }; + + Ok(Self { dir: tempdir, fd }) + } +} + +impl Drop for TempMount { + fn drop(&mut self) { + match unmount(self.dir.path(), UnmountFlags::DETACH) { + Ok(_) => {} + Err(e) => tracing::warn!("Failed to unmount tempdir: {e:?}"), + } + } +} diff --git a/crates/ostree-ext/src/lib.rs b/crates/ostree-ext/src/lib.rs index 40cd2d084..0c53ec618 100644 --- a/crates/ostree-ext/src/lib.rs +++ b/crates/ostree-ext/src/lib.rs @@ -17,6 +17,8 @@ // "Dependencies are re-exported". Users will need e.g. `gio::File`, so this avoids // them needing to update matching versions. pub use composefs; +pub use composefs_boot; +pub use composefs_oci; pub use containers_image_proxy; pub use containers_image_proxy::oci_spec; pub use ostree; diff --git a/systemd/composefs-finalize-staged.service b/systemd/composefs-finalize-staged.service new file mode 100644 index 000000000..60e3f0683 --- /dev/null +++ b/systemd/composefs-finalize-staged.service @@ -0,0 +1,46 @@ +# Copyright (C) 2018 Red Hat, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +# For some implementation discussion, see: +# https://lists.freedesktop.org/archives/systemd-devel/2018-March/040557.html +[Unit] +Description=Composefs Finalize Staged Deployment +Documentation=man:bootc(1) +DefaultDependencies=no + +RequiresMountsFor=/sysroot +After=local-fs.target +Before=basic.target final.target +# We want to make sure the transaction logs are persisted to disk: +# https://bugzilla.redhat.com/show_bug.cgi?id=1751272 +After=systemd-journal-flush.service +Conflicts=final.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStop=/usr/bin/bootc composefs-finalize-staged +# This is a quite long timeout intentionally; the failure mode +# here is that people don't get an upgrade. We need to handle +# cases with slow rotational media, etc. +TimeoutStopSec=5m +# Bootc should never touch /var at all...except, we need to remove +# the /var/.updated flag, so we can't just `InaccessiblePaths=/var` right now. +# For now, let's at least use ProtectHome just so we have some sandboxing +# of that. +ProtectHome=yes +# And we shouldn't affect the current deployment's /etc. +ReadOnlyPaths=/etc +# We write to /sysroot and /boot of course.