diff --git a/crates/lib/src/bls_config.rs b/crates/lib/src/bls_config.rs deleted file mode 100644 index 16ffceb78..000000000 --- a/crates/lib/src/bls_config.rs +++ /dev/null @@ -1,88 +0,0 @@ -use anyhow::Result; -use serde::de::Error; -use serde::{Deserialize, Deserializer}; -use std::collections::HashMap; - -#[derive(Debug, Deserialize, Eq)] -pub(crate) struct BLSConfig { - pub(crate) title: Option, - #[serde(deserialize_with = "deserialize_version")] - pub(crate) version: u32, - pub(crate) linux: String, - pub(crate) initrd: String, - pub(crate) options: String, - - #[serde(flatten)] - pub(crate) extra: HashMap, -} - -impl PartialEq for BLSConfig { - fn eq(&self, other: &Self) -> bool { - self.version == other.version - } -} - -impl PartialOrd for BLSConfig { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for BLSConfig { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.version.cmp(&other.version) - } -} - -impl BLSConfig { - pub(crate) fn to_string(&self) -> String { - let mut out = String::new(); - - if let Some(title) = &self.title { - out += &format!("title {}\n", title); - } - - out += &format!("version {}\n", self.version); - out += &format!("linux {}\n", self.linux); - out += &format!("initrd {}\n", self.initrd); - out += &format!("options {}\n", self.options); - - for (key, value) in &self.extra { - out += &format!("{} {}\n", key, value); - } - - out - } -} - -fn deserialize_version<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let s: Option = Option::deserialize(deserializer)?; - - match s { - Some(s) => Ok(s.parse::().map_err(D::Error::custom)?), - None => Err(D::Error::custom("Version not found")), - } -} - -pub(crate) fn parse_bls_config(input: &str) -> Result { - let mut map = HashMap::new(); - - for line in input.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - if let Some((key, value)) = line.split_once(' ') { - map.insert(key.to_string(), value.trim().to_string()); - } - } - - let value = serde_json::to_value(map)?; - let parsed: BLSConfig = serde_json::from_value(value)?; - - Ok(parsed) -} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index e0aeabe20..e73036fb8 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -819,13 +819,29 @@ async fn upgrade_composefs(_opts: UpgradeOpts) -> Result<()> { }; let boot_type = BootType::from(&entry); + let mut boot_digest = None; match boot_type { - BootType::Bls => setup_composefs_bls_boot(BootSetupType::Upgrade, repo, &id, entry), - BootType::Uki => setup_composefs_uki_boot(BootSetupType::Upgrade, repo, &id, entry), - }?; + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade, + repo, + &id, + entry, + )?) + } - write_composefs_state(&Utf8PathBuf::from("/sysroot"), id, imgref, true, boot_type)?; + BootType::Uki => setup_composefs_uki_boot(BootSetupType::Upgrade, repo, &id, entry)?, + }; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + imgref, + true, + boot_type, + boot_digest, + )?; Ok(()) } @@ -987,11 +1003,19 @@ async fn switch_composefs(opts: SwitchOpts) -> Result<()> { }; let boot_type = BootType::from(&entry); + let mut boot_digest = None; match boot_type { - BootType::Bls => setup_composefs_bls_boot(BootSetupType::Upgrade, repo, &id, entry), - BootType::Uki => setup_composefs_uki_boot(BootSetupType::Upgrade, repo, &id, entry), - }?; + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade, + repo, + &id, + entry, + )?) + } + BootType::Uki => setup_composefs_uki_boot(BootSetupType::Upgrade, repo, &id, entry)?, + }; write_composefs_state( &Utf8PathBuf::from("/sysroot"), @@ -999,6 +1023,7 @@ async fn switch_composefs(opts: SwitchOpts) -> Result<()> { &target_imgref, true, boot_type, + boot_digest, )?; Ok(()) diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs new file mode 100644 index 000000000..352eab0fa --- /dev/null +++ b/crates/lib/src/composefs_consts.rs @@ -0,0 +1,35 @@ +/// 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 rollback boot loader entries +pub(crate) const ROLLBACK_BOOT_LOADER_ENTRIES: &str = STAGED_BOOT_LOADER_ENTRIES; + +/// 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"; +/// Filename for rollback grub user config +pub(crate) const USER_CFG_ROLLBACK: &str = USER_CFG_STAGED; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 60383f047..20d31e4b4 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -3,8 +3,9 @@ //! Create a merged filesystem tree with the image and mounted configmaps. use std::collections::HashSet; +use std::fmt::Write as _; use std::fs::create_dir_all; -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use std::path::PathBuf; use anyhow::Ok; @@ -24,12 +25,15 @@ use ostree_ext::sysroot::SysrootLock; use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; -use crate::bls_config::{parse_bls_config, BLSConfig}; -#[allow(unused_imports)] -use crate::install::{get_efi_uuid_source, get_user_config, BootType}; +use crate::composefs_consts::{ + BOOT_LOADER_ENTRIES, ROLLBACK_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_ROLLBACK, +}; +use crate::install::{get_efi_uuid_source, BootType}; +use crate::parsers::bls_config::{parse_bls_config, BLSConfig}; +use crate::parsers::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; use crate::spec::ImageReference; -use crate::spec::{BootEntry, BootOrder, HostSpec}; +use crate::spec::{BootOrder, HostSpec}; use crate::status::{composefs_deployment_status, labels_of_config}; use crate::store::Storage; use crate::utils::async_task_with_spinner; @@ -744,53 +748,80 @@ pub(crate) async fn stage( } #[context("Rolling back UKI")] -pub(crate) fn rollback_composefs_uki(_current: &BootEntry, _rollback: &BootEntry) -> Result<()> { - unimplemented!() - // let user_cfg_name = "grub2/user.cfg.staged"; - // let user_cfg_path = PathBuf::from("boot").join(user_cfg_name); - // let sysroot = &Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())?; - - // let efi_uuid_source = get_efi_uuid_source(); - - // let rollback_verity = if let Some(composefs) = &rollback.composefs { - // composefs.verity.clone() - // } else { - // // Shouldn't really happen - // anyhow::bail!("Verity not found for rollback deployment") - // }; - // let rollback_config = get_user_config(todo!(), &rollback_verity).as_bytes(); - - // let current_verity = if let Some(composefs) = ¤t.composefs { - // composefs.verity.clone() - // } else { - // // Shouldn't really happen - // anyhow::bail!("Verity not found for booted deployment") - // }; - // let current_config = get_user_config(todo!(), ¤t_verity).as_bytes(); - - // // TODO: Need to check if user.cfg.staged exists - // sysroot - // .atomic_replace_with(user_cfg_path, |w| { - // write!(w, "{efi_uuid_source}")?; - // w.write_all(rollback_config)?; - // w.write_all(current_config)?; - // Ok(()) - // }) - // .with_context(|| format!("Writing {user_cfg_name}"))?; - - // Ok(()) +pub(crate) fn rollback_composefs_uki() -> Result<()> { + let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); + + let mut str = String::new(); + let boot_dir = + cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) + .context("Opening boot dir")?; + let mut menuentries = + get_sorted_uki_boot_entries(&boot_dir, &mut str).context("Getting UKI boot entries")?; + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(menuentries.len() == 2); + + let (first, second) = menuentries.split_at_mut(1); + std::mem::swap(&mut first[0], &mut second[0]); + + let mut buffer = get_efi_uuid_source(); + + for entry in menuentries { + write!(buffer, "{entry}")?; + } + + let entries_dir = + cap_std::fs::Dir::open_ambient_dir(&user_cfg_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {user_cfg_path:?}"))?; + + entries_dir + .atomic_write(USER_CFG_ROLLBACK, buffer) + .with_context(|| format!("Writing to {USER_CFG_ROLLBACK}"))?; + + tracing::debug!("Atomically exchanging for {USER_CFG_ROLLBACK} and {USER_CFG}"); + renameat_with( + &entries_dir, + USER_CFG_ROLLBACK, + &entries_dir, + USER_CFG, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {USER_CFG_ROLLBACK}"); + rustix::fs::unlinkat(&entries_dir, USER_CFG_ROLLBACK, AtFlags::empty()).context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + fsync( + entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening {user_cfg_path:?} as owned fd"))?, + ) + .with_context(|| format!("fsync {user_cfg_path:?}"))?; + + Ok(()) } -/// Filename for `loader/entries` -const CURRENT_ENTRIES: &str = "entries"; -const STAGED_ENTRIES: &str = "entries.staged"; -const ROLLBACK_ENTRIES: &str = STAGED_ENTRIES; +// 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 boot entries")] -pub(crate) fn get_sorted_boot_entries(ascending: bool) -> Result> { +#[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 std::fs::read_dir(format!("/sysroot/boot/loader/{CURRENT_ENTRIES}"))? { + for entry in boot_dir.read_dir(format!("loader/{BOOT_LOADER_ENTRIES}"))? { let entry = entry?; let file_name = entry.file_name(); @@ -803,8 +834,13 @@ pub(crate) fn get_sorted_boot_entries(ascending: bool) -> Result> continue; } - let contents = std::fs::read_to_string(&entry.path()) - .with_context(|| format!("Failed to read {:?}", entry.path()))?; + 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")?; @@ -818,21 +854,28 @@ pub(crate) fn get_sorted_boot_entries(ascending: bool) -> Result> #[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_boot_entries(false)?; + 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.version = idx as u32; } + // TODO(Johan-Liebert): Currently assuming there are only two deployments assert!(all_configs.len() == 2); // Write these - let dir_path = PathBuf::from(format!("/sysroot/boot/loader/{ROLLBACK_ENTRIES}")); + let dir_path = PathBuf::from(format!( + "/sysroot/boot/loader/{ROLLBACK_BOOT_LOADER_ENTRIES}" + )); create_dir_all(&dir_path).with_context(|| format!("Failed to create dir: {dir_path:?}"))?; let rollback_entries_dir = @@ -844,7 +887,7 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { let file_name = format!("bootc-composefs-{}.conf", cfg.version); rollback_entries_dir - .atomic_write(&file_name, cfg.to_string().as_bytes()) + .atomic_write(&file_name, cfg.to_string()) .with_context(|| format!("Writing to {file_name}"))?; } @@ -860,18 +903,21 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { let dir = Dir::open_ambient_dir("/sysroot/boot/loader", cap_std::ambient_authority()) .context("Opening loader dir")?; - tracing::debug!("Atomically exchanging for {ROLLBACK_ENTRIES} and {CURRENT_ENTRIES}"); + tracing::debug!( + "Atomically exchanging for {ROLLBACK_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}" + ); renameat_with( &dir, - ROLLBACK_ENTRIES, + ROLLBACK_BOOT_LOADER_ENTRIES, &dir, - CURRENT_ENTRIES, + BOOT_LOADER_ENTRIES, RenameFlags::EXCHANGE, ) .context("renameat")?; - tracing::debug!("Removing {ROLLBACK_ENTRIES}"); - rustix::fs::unlinkat(&dir, ROLLBACK_ENTRIES, AtFlags::REMOVEDIR).context("unlinkat")?; + tracing::debug!("Removing {ROLLBACK_BOOT_LOADER_ENTRIES}"); + rustix::fs::unlinkat(&dir, ROLLBACK_BOOT_LOADER_ENTRIES, AtFlags::empty()) + .context("unlinkat")?; tracing::debug!("Syncing to disk"); fsync( @@ -915,9 +961,15 @@ pub(crate) async fn composefs_rollback() -> Result<()> { match rollback_composefs_entry.boot_type { BootType::Bls => rollback_composefs_bls(), - BootType::Uki => rollback_composefs_uki(&host.status.booted.unwrap(), &rollback_status), + BootType::Uki => rollback_composefs_uki(), }?; + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + Ok(()) } @@ -1130,6 +1182,10 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { #[cfg(test)] mod tests { + use std::collections::HashMap; + + use crate::parsers::grub_menuconfig::MenuentryBody; + use super::*; #[test] @@ -1224,4 +1280,119 @@ UUID=6907-17CA /boot/efi vfat umask=0077,shortname=win assert_eq!(tempdir.read_to_string("etc/fstab")?, modified); Ok(()) } + + #[test] + fn test_sorted_bls_boot_entries() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let entry1 = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version 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 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); + + let mut expected = vec![ + BLSConfig { + title: Some("Fedora 42.20250623.3.1 (CoreOS)".into()), + version: 1, + linux: "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(), + initrd: "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into(), + options: "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into(), + extra: HashMap::new(), + }, + BLSConfig { + title: Some("Fedora 41.20250214.2.0 (CoreOS)".into()), + version: 2, + linux: "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(), + initrd: "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into(), + options: "root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into(), + extra: HashMap::new(), + }, + ]; + + assert_eq!(result.unwrap(), expected); + + let result = get_sorted_bls_boot_entries(&tempdir, false); + expected.reverse(); + assert_eq!(result.unwrap(), expected); + + Ok(()) + } + + #[test] + fn test_sorted_uki_boot_entries() -> Result<()> { + let user_cfg = r#" + if [ -f ${config_directory}/efiuuid.cfg ]; then + source ${config_directory}/efiuuid.cfg + fi + + menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi + } + + menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi + } + "#; + + let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + bootdir.create_dir_all(format!("grub2"))?; + bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?; + + let mut s = String::new(); + let result = get_sorted_uki_boot_entries(&bootdir, &mut s)?; + + let expected = vec![ + MenuEntry { + title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + MenuEntry { + title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + ]; + + assert_eq!(result, expected); + + Ok(()) + } } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 05c099729..3b07ed997 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -48,6 +48,7 @@ use ostree_ext::composefs::{ repository::Repository as ComposefsRepository, util::Sha256Digest, }; +use ostree_ext::composefs_boot::bootloader::UsrLibModulesVmlinuz; use ostree_ext::composefs_boot::{ bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, uki, BootOps, }; @@ -72,17 +73,27 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "install-to-disk")] use self::baseline::InstallBlockDeviceOpts; -use crate::bls_config::{parse_bls_config, BLSConfig}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; +use crate::composefs_consts::{ + BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, + SHARED_VAR_PATH, STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, STATE_DIR_RELATIVE, USER_CFG, + USER_CFG_STAGED, +}; use crate::containerenv::ContainerExecutionInfo; -use crate::deploy::{prepare_for_pull, pull_from_prepared, PreparedImportMeta, PreparedPullResult}; +use crate::deploy::{ + get_sorted_uki_boot_entries, prepare_for_pull, pull_from_prepared, PreparedImportMeta, + PreparedPullResult, +}; use crate::kernel_cmdline::Cmdline; use crate::lsm; +use crate::parsers::bls_config::{parse_bls_config, BLSConfig}; +use crate::parsers::grub_menuconfig::MenuEntry; use crate::progress_jsonl::ProgressWriter; use crate::spec::ImageReference; use crate::store::Storage; use crate::task::Task; -use crate::utils::sigpolicy_from_opt; +use crate::utils::{path_relative_to, sigpolicy_from_opt}; use bootc_mount::{inspect_filesystem, Filesystem}; /// The toplevel boot directory @@ -1543,7 +1554,7 @@ async fn initialize_composefs_repository( rootfs_dir .create_dir_all("composefs") - .context("Creating dir 'composefs'")?; + .context("Creating dir composefs")?; let repo = open_composefs_repo(rootfs_dir)?; @@ -1559,7 +1570,7 @@ async fn initialize_composefs_repository( fn get_booted_bls() -> Result { let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; let booted = cmdline - .find_str("composefs") + .find_str(COMPOSEFS_CMDLINE) .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; for entry in std::fs::read_dir("/sysroot/boot/loader/entries")? { @@ -1605,6 +1616,133 @@ pub(crate) enum BootSetupType<'a> { Upgrade, } +/// Compute SHA256Sum of VMlinuz + Initrd +/// +/// # Arguments +/// * entry - BootEntry containing VMlinuz and Initrd +/// * repo - The composefs repository +#[context("Computing boot digest")] +fn compute_boot_digest( + entry: &UsrLibModulesVmlinuz, + repo: &ComposefsRepository, +) -> Result { + let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + let initramfs = read_file(initramfs, &repo).context("Reading intird")?; + + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) + .context("Creating hasher")?; + + hasher.update(&vmlinuz).context("hashing vmlinuz")?; + hasher.update(&initramfs).context("hashing initrd")?; + + let digest: &[u8] = &hasher.finish().context("Finishing digest")?; + + return Ok(hex::encode(digest)); +} + +/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum +/// +/// # Returns +/// Returns the verity of the deployment that has a boot digest same as the one passed in +#[context("Checking boot entry duplicates")] +fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { + let deployments = + cap_std::fs::Dir::open_ambient_dir(STATE_DIR_ABS, cap_std::ambient_authority()); + + let deployments = match deployments { + Ok(d) => d, + // The first ever deployment + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => anyhow::bail!(e), + }; + + let mut symlink_to: Option = None; + + for depl in deployments.entries()? { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.as_str()?; + + let config = depl + .open_dir() + .with_context(|| format!("Opening {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .context("Reading origin file")?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + match ini.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST) { + Some(hash) => { + if hash == digest { + symlink_to = Some(depl_file_name.to_string()); + break; + } + } + + // No SHASum recorded in origin file + // `symlink_to` is already none, but being explicit here + None => symlink_to = None, + }; + } + + Ok(symlink_to) +} + +#[context("Writing BLS entries to disk")] +fn write_bls_boot_entries_to_disk( + boot_dir: &Utf8PathBuf, + deployment_id: &Sha256HashValue, + entry: &UsrLibModulesVmlinuz, + repo: &ComposefsRepository, +) -> Result<()> { + let id_hex = deployment_id.to_hex(); + + // Write the initrd and vmlinuz at /boot// + let path = boot_dir.join(&id_hex); + create_dir_all(&path)?; + + let entries_dir = cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + + entries_dir + .atomic_write( + "vmlinuz", + read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?, + ) + .context("Writing vmlinuz to path")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + entries_dir + .atomic_write( + "initrd", + read_file(initramfs, &repo).context("Reading initrd")?, + ) + .context("Writing initrd to path")?; + + // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd + let owned_fd = entries_dir + .reopen_as_ownedfd() + .context("Reopen as owned fd")?; + + rustix::fs::fsync(owned_fd).context("fsync")?; + + Ok(()) +} + +/// 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, @@ -1612,7 +1750,7 @@ pub(crate) fn setup_composefs_bls_boot( repo: ComposefsRepository, id: &Sha256HashValue, entry: ComposefsBootEntry, -) -> Result<()> { +) -> Result { let id_hex = id.to_hex(); let (root_path, cmdline_refs) = match setup_type { @@ -1622,10 +1760,10 @@ pub(crate) fn setup_composefs_bls_boot( match &state.composefs_options { Some(opt) if opt.insecure => { - cmdline_options.push_str(&format!(" composefs=?{id_hex}")); + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}=?{id_hex}")); } None | Some(..) => { - cmdline_options.push_str(&format!(" composefs={id_hex}")); + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}={id_hex}")); } }; @@ -1637,73 +1775,55 @@ pub(crate) fn setup_composefs_bls_boot( vec![ format!("root=UUID={DPS_UUID}"), RW_KARG.to_string(), - format!("composefs={id_hex}"), + format!("{COMPOSEFS_CMDLINE}={id_hex}"), ] .join(" "), ), }; let boot_dir = root_path.join("boot"); + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade); - let bls_config = match &entry { + let (bls_config, boot_digest) = match &entry { ComposefsBootEntry::Type1(..) => unimplemented!(), ComposefsBootEntry::Type2(..) => unimplemented!(), ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { - // Write the initrd and vmlinuz at /boot// - let path = boot_dir.join(&id_hex); - create_dir_all(&path)?; - - let entries_dir = - cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {path}"))?; - - entries_dir - .atomic_write( - "vmlinuz", - read_file(&usr_lib_modules_vmlinuz.vmlinuz, &repo) - .context("Reading vmlinuz")?, - ) - .context("Writing vmlinuz to path")?; - - if let Some(initramfs) = &usr_lib_modules_vmlinuz.initramfs { - entries_dir - .atomic_write( - "initrd", - read_file(initramfs, &repo).context("Reading initrd")?, - ) - .context("Writing initrd to path")?; - } else { - anyhow::bail!("initramfs not found"); - }; - - // 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")?; + let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) + .context("Computing boot digest")?; - BLSConfig { + let mut bls_config = BLSConfig { title: Some(id_hex.clone()), version: 1, linux: format!("/boot/{id_hex}/vmlinuz"), initrd: format!("/boot/{id_hex}/initrd"), options: cmdline_refs, extra: HashMap::new(), + }; + + if let Some(symlink_to) = find_vmlinuz_initrd_duplicates(&boot_digest)? { + bls_config.linux = format!("/boot/{symlink_to}/vmlinuz"); + bls_config.initrd = format!("/boot/{symlink_to}/initrd"); + } else { + write_bls_boot_entries_to_disk(&boot_dir, id, usr_lib_modules_vmlinuz, &repo)?; } + + (bls_config, boot_digest) } }; - let (entries_path, booted_bls) = if matches!(setup_type, BootSetupType::Upgrade) { + let (entries_path, booted_bls) = if is_upgrade { let mut booted_bls = get_booted_bls()?; booted_bls.version = 0; // entries are sorted by their filename in reverse order // This will be atomically renamed to 'loader/entries' on shutdown/reboot - (boot_dir.join("loader/entries.staged"), Some(booted_bls)) + ( + boot_dir.join(format!("loader/{STAGED_BOOT_LOADER_ENTRIES}")), + Some(booted_bls), + ) } else { - (boot_dir.join("loader/entries"), None) + (boot_dir.join(format!("loader/{BOOT_LOADER_ENTRIES}")), None) }; create_dir_all(&entries_path).with_context(|| format!("Creating {:?}", entries_path))?; @@ -1729,7 +1849,7 @@ pub(crate) fn setup_composefs_bls_boot( .context("Reopening as owned fd")?; rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?; - Ok(()) + Ok(boot_digest) } pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { @@ -1743,22 +1863,6 @@ pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { Ok((esp.node, esp.uuid)) } -pub(crate) fn get_user_config(boot_label: &String, uki_id: &str) -> String { - // TODO: Full EFI path here - let s = format!( - r#" -menuentry "{boot_label}: ({uki_id})" {{ - insmod fat - insmod chain - search --no-floppy --set=root --fs-uuid "${{EFI_PART_UUID}}" - chainloader /EFI/Linux/{uki_id}.efi -}} -"# - ); - - return s; -} - /// Contains the EFP's filesystem UUID. Used by grub pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; @@ -1907,9 +2011,9 @@ pub(crate) fn setup_composefs_uki_boot( let efi_uuid_source = get_efi_uuid_source(); let user_cfg_name = if is_upgrade { - "user.cfg.staged" + USER_CFG_STAGED } else { - "user.cfg" + USER_CFG }; let grub_dir = @@ -1924,18 +2028,21 @@ pub(crate) fn setup_composefs_uki_boot( // Shouldn't really fail so no context here buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all(get_user_config(&boot_label, &id.to_hex()).as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; - // root_path here will be /sysroot - for entry in std::fs::read_dir(root_path.join(STATE_DIR_RELATIVE))? { - let entry = entry?; + let mut str_buf = String::new(); + let boot_dir = cap_std::fs::Dir::open_ambient_dir(boot_dir, cap_std::ambient_authority()) + .context("Opening boot dir")?; + let entries = get_sorted_uki_boot_entries(&boot_dir, &mut str_buf)?; - let depl_file_name = entry.file_name(); - // SAFETY: Deployment file name shouldn't containg non UTF-8 chars - let depl_file_name = depl_file_name.to_string_lossy(); - - buffer.write_all(get_user_config(&boot_label, &depl_file_name).as_bytes())?; - } + // 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) @@ -1962,7 +2069,11 @@ pub(crate) fn setup_composefs_uki_boot( // Shouldn't really fail so no context here buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all(get_user_config(&boot_label, &id.to_hex()).as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; grub_dir .atomic_write(user_cfg_name, buffer) @@ -2039,14 +2150,19 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - }; let boot_type = BootType::from(&entry); + let mut boot_digest: Option = None; match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state)), - repo, - &id, - entry, - )?, + BootType::Bls => { + let digest = setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state)), + repo, + &id, + entry, + )?; + + boot_digest = Some(digest); + } BootType::Uki => setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state)), repo, @@ -2065,19 +2181,12 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - }, false, boot_type, + boot_digest, )?; Ok(()) } -pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; -pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_PATH: &str = "/run/composefs/staged-deployment"; -/// Relative to /sysroot -pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; - -pub(crate) const ORIGIN_KEY_BOOT: &str = "boot"; -pub(crate) const ORIGIN_KEY_BOOT_TYPE: &str = "boot_type"; - /// Creates and populates /sysroot/state/deploy/image_id #[context("Writing composefs state")] pub(crate) fn write_composefs_state( @@ -2086,17 +2195,22 @@ pub(crate) fn write_composefs_state( 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/upper"))?; create_dir_all(state_path.join("etc/work"))?; - let actual_var_path = root_path.join(format!("state/os/fedora/var")); + let actual_var_path = root_path.join(SHARED_VAR_PATH); create_dir_all(&actual_var_path)?; - symlink(Path::new("../../os/fedora/var"), state_path.join("var")) - .context("Failed to create symlink for /var")?; + 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, @@ -2113,21 +2227,38 @@ pub(crate) fn write_composefs_state( .section(ORIGIN_KEY_BOOT) .item(ORIGIN_KEY_BOOT_TYPE, boot_type); - let mut origin_file = - std::fs::File::create(state_path.join(format!("{}.origin", deployment_id.to_hex()))) - .context("Failed to open .origin file")?; + 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")?; - origin_file - .write(config.to_string().as_bytes()) + state_dir + .atomic_write( + format!("{}.origin", deployment_id.to_hex()), + config.to_string().as_bytes(), + ) .context("Falied 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 buf = deployment_id.to_hex(); - std::fs::write(COMPOSEFS_STAGED_DEPLOYMENT_PATH, buf) - .with_context(|| format!("Writing {COMPOSEFS_STAGED_DEPLOYMENT_PATH}"))?; + 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/kernel_cmdline.rs b/crates/lib/src/kernel_cmdline.rs index a961d2f31..b725e1b95 100644 --- a/crates/lib/src/kernel_cmdline.rs +++ b/crates/lib/src/kernel_cmdline.rs @@ -328,7 +328,7 @@ mod tests { // non-UTF8 things are in fact valid let non_utf8_byte = b"\xff"; #[allow(invalid_from_utf8)] - let failed_conversion = str::from_utf8(non_utf8_byte); + let failed_conversion = std::str::from_utf8(non_utf8_byte); assert!(failed_conversion.is_err()); let mut p = b"foo=".to_vec(); p.push(non_utf8_byte[0]); diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 9971b8385..a7c02391d 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -4,11 +4,11 @@ //! to provide a fully "container native" tool for using //! bootable container images. -mod bls_config; pub(crate) mod bootc_kargs; mod boundimage; mod cfsctl; pub mod cli; +mod composefs_consts; pub(crate) mod deploy; pub(crate) mod fsck; pub(crate) mod generator; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs new file mode 100644 index 000000000..b5cbff240 --- /dev/null +++ b/crates/lib/src/parsers/bls_config.rs @@ -0,0 +1,201 @@ +use anyhow::Result; +use serde::de::Error; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; +use std::fmt::Display; + +#[derive(Debug, Deserialize, Eq)] +pub(crate) struct BLSConfig { + pub(crate) title: Option, + #[serde(deserialize_with = "deserialize_version")] + pub(crate) version: u32, + pub(crate) linux: String, + pub(crate) initrd: String, + pub(crate) options: String, + + #[serde(flatten)] + pub(crate) extra: HashMap, +} + +impl PartialEq for BLSConfig { + fn eq(&self, other: &Self) -> bool { + self.version == other.version + } +} + +impl PartialOrd for BLSConfig { + fn partial_cmp(&self, other: &Self) -> Option { + self.version.partial_cmp(&other.version) + } +} + +impl Ord for BLSConfig { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.version.cmp(&other.version) + } +} + +impl Display for BLSConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(title) = &self.title { + writeln!(f, "title {}", title)?; + } + + writeln!(f, "version {}", self.version)?; + writeln!(f, "linux {}", self.linux)?; + writeln!(f, "initrd {}", self.initrd)?; + writeln!(f, "options {}", self.options)?; + + for (key, value) in &self.extra { + writeln!(f, "{} {}", key, value)?; + } + + Ok(()) + } +} + +fn deserialize_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + + match s { + Some(s) => Ok(s.parse::().map_err(D::Error::custom)?), + None => Err(D::Error::custom("Version not found")), + } +} + +pub(crate) fn parse_bls_config(input: &str) -> Result { + let mut map = HashMap::new(); + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(' ') { + map.insert(key.to_string(), value.trim().to_string()); + } + } + + let value = serde_json::to_value(map)?; + let parsed: BLSConfig = serde_json::from_value(value)?; + + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_bls_config() -> Result<()> { + let input = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version 2 + linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 + initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 + custom1 value1 + custom2 value2 + "#; + + let config = parse_bls_config(input)?; + + assert_eq!( + config.title, + Some("Fedora 42.20250623.3.1 (CoreOS)".to_string()) + ); + assert_eq!(config.version, 2); + assert_eq!(config.linux, "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"); + assert_eq!(config.initrd, "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"); + assert_eq!(config.options, "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"); + assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string())); + assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string())); + + Ok(()) + } + + #[test] + fn test_parse_missing_version() { + let input = r#" + title Fedora + linux /vmlinuz + initrd /initramfs.img + options root=UUID=xyz ro quiet + "#; + + let parsed = parse_bls_config(input); + assert!(parsed.is_err()); + } + + #[test] + fn test_parse_invalid_version_format() { + let input = r#" + title Fedora + version not_an_int + linux /vmlinuz + initrd /initramfs.img + options root=UUID=abc composefs=some-uuid + "#; + + let parsed = parse_bls_config(input); + assert!(parsed.is_err()); + } + + #[test] + fn test_display_output() -> Result<()> { + let input = r#" + title Test OS + version 10 + linux /boot/vmlinuz + initrd /boot/initrd.img + options root=UUID=abc composefs=some-uuid + foo bar + "#; + + let config = parse_bls_config(input)?; + let output = format!("{}", config); + let mut output_lines = output.lines(); + + assert_eq!(output_lines.next().unwrap(), "title Test OS"); + assert_eq!(output_lines.next().unwrap(), "version 10"); + assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz"); + assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img"); + assert_eq!( + output_lines.next().unwrap(), + "options root=UUID=abc composefs=some-uuid" + ); + assert_eq!(output_lines.next().unwrap(), "foo bar"); + + Ok(()) + } + + #[test] + fn test_ordering() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 < config2); + Ok(()) + } +} diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 763f9c292..35a3c8414 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -14,13 +14,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 +31,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 +46,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)), } @@ -74,7 +71,7 @@ impl<'a> From> for MenuentryBody<'a> { #[allow(dead_code)] 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>, } @@ -87,6 +84,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. #[allow(dead_code)] pub fn take_until_balanced_allow_nested( @@ -183,7 +196,7 @@ fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry> { Ok(( input, MenuEntry { - title, + title: title.to_string(), body: MenuentryBody::from(map), }, )) @@ -278,20 +291,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") @@ -318,7 +333,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] @@ -367,8 +382,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); } @@ -386,8 +401,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] @@ -405,7 +420,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")); } @@ -427,7 +442,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")); } @@ -506,12 +521,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/parsers/mod.rs b/crates/lib/src/parsers/mod.rs index ca3d0453a..e3640c8ef 100644 --- a/crates/lib/src/parsers/mod.rs +++ b/crates/lib/src/parsers/mod.rs @@ -1 +1,2 @@ +pub(crate) mod bls_config; pub(crate) mod grub_menuconfig; diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index eef64b28c..a760ed616 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -24,11 +24,13 @@ use ostree_ext::ostree; use tokio::io::AsyncReadExt; use crate::cli::OutputFormat; -use crate::deploy::get_sorted_boot_entries; +use crate::composefs_consts::{ + COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, +}; +use crate::deploy::get_sorted_bls_boot_entries; +use crate::deploy::get_sorted_uki_boot_entries; use crate::install::BootType; -use crate::install::ORIGIN_KEY_BOOT; -use crate::install::ORIGIN_KEY_BOOT_TYPE; -use crate::install::{COMPOSEFS_STAGED_DEPLOYMENT_PATH, STATE_DIR_RELATIVE}; use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; use crate::spec::{ImageReference, ImageSignature}; @@ -408,7 +410,7 @@ async fn boot_entry_from_composefs_deployment( pub(crate) async fn composefs_deployment_status() -> Result { let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; let composefs_arg = cmdline - .find_str("composefs") + .find_str(COMPOSEFS_CMDLINE) .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; let booted_image_verity = composefs_arg .value @@ -427,7 +429,9 @@ pub(crate) async fn composefs_deployment_status() -> Result { let mut host = Host::new(host_spec); - let staged_deployment_id = match std::fs::File::open(COMPOSEFS_STAGED_DEPLOYMENT_PATH) { + 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)?; @@ -438,6 +442,9 @@ pub(crate) async fn composefs_deployment_status() -> Result { 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?; @@ -457,6 +464,21 @@ pub(crate) async fn composefs_deployment_status() -> Result { 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() == booted_image_verity { host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); host.status.booted = Some(boot_entry); @@ -473,11 +495,33 @@ pub(crate) async fn composefs_deployment_status() -> Result { host.status.rollback = Some(boot_entry); } - host.status.rollback_queued = !get_sorted_boot_entries(false)? - .first() - .ok_or(anyhow::anyhow!("First boot entry not found"))? - .options - .contains(composefs_arg.as_ref()); + // 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 + .contains(composefs_arg.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_arg.as_ref()) + } + }; if host.status.rollback_queued { host.spec.boot_order = BootOrder::Rollback diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index f83a718c3..8e0a338a1 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -1,8 +1,9 @@ -use std::future::Future; use std::io::Write; use std::os::fd::BorrowedFd; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +use std::{future::Future, path::Component}; use anyhow::{Context, Result}; use bootc_utils::CommandRunExt; @@ -186,6 +187,28 @@ 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. +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 +246,21 @@ mod tests { SignatureSource::ContainerPolicyAllowInsecure ); } + + #[test] + 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" + ); + } }