diff --git a/Cargo.lock b/Cargo.lock index 3b1c2e749..9ea0f7a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,8 +245,7 @@ dependencies = [ "liboverdrop", "libsystemd", "linkme", - "openat", - "openat-ext", + "nom 8.0.0", "openssl", "ostree-ext", "regex", @@ -1409,7 +1408,7 @@ dependencies = [ "libc", "log", "nix 0.27.1", - "nom", + "nom 7.1.3", "once_cell", "serde", "sha2", @@ -1581,19 +1580,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "nix" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" -dependencies = [ - "bitflags 1.3.2", - "cc", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.25.1" @@ -1630,6 +1616,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1707,27 +1702,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "openat" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95aa7c05907b3ebde2610d602f4ddd992145cc6a84493647c30396f30ba83abe" -dependencies = [ - "libc", -] - -[[package]] -name = "openat-ext" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf3e4baa7f516441f58373f58aaf6e91a5dfa2e2b50e68a0d313b082014c61d" -dependencies = [ - "libc", - "nix 0.23.2", - "openat", - "rand 0.8.5", -] - [[package]] name = "openssh-keys" version = "0.6.4" diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 56a02faa0..3a4477abf 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -56,8 +56,7 @@ tini = "1.3.0" comfy-table = "7.1.1" thiserror = { workspace = true } canon-json = { workspace = true } -openat = "0.1.21" -openat-ext = "0.2.3" +nom = "8.0.0" [dev-dependencies] similar-asserts = { workspace = true } diff --git a/crates/lib/src/bls_config.rs b/crates/lib/src/bls_config.rs deleted file mode 100644 index 6b64be52d..000000000 --- a/crates/lib/src/bls_config.rs +++ /dev/null @@ -1,88 +0,0 @@ -use serde::{Deserialize, Deserializer}; -use serde::de::Error; -use std::collections::HashMap; -use anyhow::Result; - -#[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 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 f055549f5..03b4111c2 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -20,7 +20,7 @@ use ostree_container::store::PrepareResult; use ostree_ext::composefs::fsverity; use ostree_ext::composefs::fsverity::FsVerityHashValue; use ostree_ext::container as ostree_container; -use ostree_ext::container_utils::{composefs_booted, ostree_booted}; +use ostree_ext::container_utils::ostree_booted; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; use schemars::schema_for; @@ -36,7 +36,7 @@ use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; use crate::spec::ImageReference; use crate::status::composefs_deployment_status; -use crate::utils::sigpolicy_from_opt; +use crate::utils::{composefs_booted, sigpolicy_from_opt}; /// Shared progress options #[derive(Debug, Parser, PartialEq, Eq)] @@ -798,13 +798,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(()) } @@ -966,11 +982,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"), @@ -978,6 +1002,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..e11318956 --- /dev/null +++ b/crates/lib/src/composefs_consts.rs @@ -0,0 +1,38 @@ +/// composefs= paramter in kernel cmdline +pub const COMPOSEFS_CMDLINE: &str = "composefs="; +/// composefs=? paramter in kernel cmdline. The `?` signifies that the fs-verity validation is +/// optional in case the filesystem doesn't support it. +pub const COMPOSEFS_INSECURE_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 aa5a7c659..de1f181cb 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; @@ -22,18 +23,21 @@ use ostree_ext::ostree::Deployment; use ostree_ext::ostree::{self, Sysroot}; use ostree_ext::sysroot::SysrootLock; use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; - -use crate::bls_config::{parse_bls_config, BLSConfig}; -use crate::install::{get_efi_uuid_source, get_user_config, BootType}; +use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; + +use crate::composefs_consts::{ + BOOT_LOADER_ENTRIES, ROLLBACK_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_ROLLBACK, +}; +use crate::install::{get_efi_uuid_source, BootType}; +use crate::parsers::bls_config::{parse_bls_config, BLSConfig}; +use crate::parsers::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; use crate::spec::ImageReference; -use crate::spec::{BootOrder, HostSpec, BootEntry}; +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; -use openat_ext::OpenatDirExt; - // TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc"; @@ -743,52 +747,81 @@ pub(crate) async fn stage( Ok(()) } - #[context("Rolling back UKI")] -pub(crate) fn rollback_composefs_uki(current: &BootEntry, rollback: &BootEntry) -> Result<()> { - let user_cfg_name = "grub2/user.cfg.staged"; - let user_cfg_path = PathBuf::from("/sysroot/boot").join(user_cfg_name); +pub(crate) fn rollback_composefs_uki() -> Result<()> { + let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); - let efi_uuid_source = get_efi_uuid_source(); + 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: Need to check if user.cfg.staged exists - let mut usr_cfg = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(user_cfg_path) - .with_context(|| format!("Opening {user_cfg_name}"))?; + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(menuentries.len() == 2); - usr_cfg.write(efi_uuid_source.as_bytes())?; + let (first, second) = menuentries.split_at_mut(1); + std::mem::swap(&mut first[0], &mut second[0]); - let verity = if let Some(composefs) = &rollback.composefs { - composefs.verity.clone() - } else { - // Shouldn't really happen - anyhow::bail!("Verity not found for rollback deployment") - }; - usr_cfg.write(get_user_config(&verity).as_bytes())?; + let mut buffer = get_efi_uuid_source(); - let verity = if let Some(composefs) = ¤t.composefs { - composefs.verity.clone() - } else { - // Shouldn't really happen - anyhow::bail!("Verity not found for booted deployment") - }; - usr_cfg.write(get_user_config(&verity).as_bytes())?; + 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 ROLLBACK_ENTRIES: &str = "entries.staged"; +// 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(); @@ -801,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")?; @@ -816,50 +854,77 @@ 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 = + 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 { let file_name = format!("bootc-composefs-{}.conf", cfg.version); - let mut file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .open(dir_path.join(&file_name)) - .with_context(|| format!("Opening {file_name}"))?; - - file.write_all(cfg.to_string().as_bytes()) + 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 = openat::Dir::open("/sysroot/boot/loader").context("Opening loader dir")?; + 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}"); - dir.local_exchange(ROLLBACK_ENTRIES, CURRENT_ENTRIES) - .context("local exchange")?; + tracing::debug!( + "Atomically exchanging for {ROLLBACK_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}" + ); + renameat_with( + &dir, + ROLLBACK_BOOT_LOADER_ENTRIES, + &dir, + BOOT_LOADER_ENTRIES, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; - tracing::debug!("Removing {ROLLBACK_ENTRIES}"); - dir.remove_all(ROLLBACK_ENTRIES) - .context("Removing entries.rollback")?; + tracing::debug!("Removing {ROLLBACK_BOOT_LOADER_ENTRIES}"); + rustix::fs::unlinkat(&dir, ROLLBACK_BOOT_LOADER_ENTRIES, AtFlags::empty()) + .context("unlinkat")?; tracing::debug!("Syncing to disk"); - dir.syncfs().context("syncfs")?; + fsync( + dir.reopen_as_ownedfd() + .with_context(|| format!("Reopening /sysroot/boot/loader as owned fd"))?, + ) + .context("fsync")?; Ok(()) } @@ -896,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(()) } @@ -1111,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] @@ -1205,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 eb747c2ac..4eebb5884 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -49,9 +49,9 @@ 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, - write_boot::write_boot_simple as composefs_write_boot_simple, BootOps, + bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, uki, BootOps, }; use ostree_ext::composefs_oci::{ image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, @@ -69,21 +69,31 @@ use ostree_ext::{ use rustix::fs::FileTypeExt; use rustix::fs::MetadataExt as _; use rustix::path::Arg; -use serde::{Deserialize, Serialize}; use schemars::JsonSchema; +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_INSECURE_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::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 @@ -269,7 +279,9 @@ impl TryFrom<&str> for BootType { match value { "bls" => Ok(Self::Bls), "uki" => Ok(Self::Uki), - unrecognized => Err(anyhow::anyhow!("Unrecognized boot option: '{unrecognized}'")), + unrecognized => Err(anyhow::anyhow!( + "Unrecognized boot option: '{unrecognized}'" + )), } } } @@ -287,8 +299,9 @@ impl From<&ComposefsBootEntry> for BootType { #[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallComposefsOpts { - #[clap(long, value_enum, default_value_t)] - pub(crate) boot: BootType, + #[clap(long, default_value_t)] + #[serde(default)] + pub(crate) insecure: bool, } #[cfg(feature = "install-to-disk")] @@ -316,9 +329,11 @@ pub(crate) struct InstallToDiskOpts { pub(crate) via_loopback: bool, #[clap(long)] + #[serde(default)] pub(crate) composefs_native: bool, #[clap(flatten)] + #[serde(flatten)] pub(crate) composefs_opts: InstallComposefsOpts, } @@ -607,17 +622,12 @@ impl FromStr for MountSpec { impl InstallToDiskOpts { pub(crate) fn validate(&self) -> Result<()> { if !self.composefs_native { - // Reject using --boot without --composefs - if self.composefs_opts.boot != BootType::default() { - anyhow::bail!("--boot must not be provided without --composefs"); + // Reject using --insecure without --composefs + if self.composefs_opts.insecure != false { + anyhow::bail!("--insecure must not be provided without --composefs"); } } - // Can't add kargs to UKI - if self.composefs_opts.boot == BootType::Uki && self.config_opts.karg.is_some() { - anyhow::bail!("Cannot pass kargs to UKI"); - } - Ok(()) } } @@ -1533,7 +1543,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)?; @@ -1548,7 +1558,10 @@ async fn initialize_composefs_repository( fn get_booted_bls() -> Result { let cmdline = crate::kernel::parse_cmdline()?; - let booted = cmdline.iter().find_map(|x| x.strip_prefix("composefs=")); + let booted = cmdline.iter().find_map(|x| { + x.strip_prefix(COMPOSEFS_INSECURE_CMDLINE) + .or_else(|| x.strip_prefix(COMPOSEFS_CMDLINE)) + }); let Some(booted) = booted else { anyhow::bail!("Failed to find composefs parameter in kernel cmdline"); @@ -1591,11 +1604,138 @@ pub fn read_file( pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk - Setup(&'a RootSetup), + Setup((&'a RootSetup, &'a State)), /// For `bootc upgrade` 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, @@ -1603,14 +1743,22 @@ 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 { - BootSetupType::Setup(root_setup) => { + BootSetupType::Setup((root_setup, state)) => { // root_setup.kargs has [root=UUID=, "rw"] let mut cmdline_options = String::from(root_setup.kargs.join(" ")); - cmdline_options.push_str(&format!(" composefs={id_hex}")); + + match &state.composefs_options { + Some(opt) if opt.insecure => { + cmdline_options.push_str(&format!(" {COMPOSEFS_INSECURE_CMDLINE}{id_hex}")); + } + None | Some(..) => { + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}{id_hex}")); + } + }; (root_setup.physical_root_path.clone(), cmdline_options) } @@ -1620,7 +1768,7 @@ 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(" "), ), @@ -1628,71 +1776,74 @@ pub(crate) fn setup_composefs_bls_boot( let boot_dir = root_path.join("boot"); - let bls_config = match &entry { + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade); + + let (bls_config, boot_digest) = match &entry { ComposefsBootEntry::Type1(..) => todo!(), ComposefsBootEntry::Type2(..) => todo!(), ComposefsBootEntry::UsrLibModulesUki(..) => todo!(), 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 vmlinuz_path = path.join("vmlinuz"); - let initrd_path = path.join("initrd"); - - std::fs::write( - &vmlinuz_path, - 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 { - std::fs::write( - &initrd_path, - read_file(initramfs, &repo).context("Reading initramfs")?, - ) - .context("Writing initrd to path")?; - } else { - anyhow::bail!("initramfs not found"); - }; + 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))?; - std::fs::write( - entries_path.join(format!("bootc-composefs-{}.conf", bls_config.version)), + let loader_entries_dir = + cap_std::fs::Dir::open_ambient_dir(&entries_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {entries_path}"))?; + + loader_entries_dir.atomic_write( + format!("bootc-composefs-{}.conf", bls_config.version), bls_config.to_string().as_bytes(), )?; if let Some(booted_bls) = booted_bls { - std::fs::write( - entries_path.join(format!("bootc-composefs-{}.conf", booted_bls.version)), + loader_entries_dir.atomic_write( + format!("bootc-composefs-{}.conf", booted_bls.version), booted_bls.to_string().as_bytes(), )?; } - Ok(()) + 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")?; + + Ok(boot_digest) } pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { @@ -1706,21 +1857,6 @@ pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { Ok((esp.node, esp.uuid)) } -pub(crate) fn get_user_config(uki_id: &str) -> String { - let s = format!( - r#" -menuentry "Fedora Bootc UKI: ({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"; @@ -1744,8 +1880,14 @@ pub(crate) fn setup_composefs_uki_boot( id: &Sha256HashValue, entry: ComposefsBootEntry, ) -> Result<()> { - let (root_path, esp_device) = match setup_type { - BootSetupType::Setup(root_setup) => { + let (root_path, esp_device, is_insecure_from_opts) = match setup_type { + BootSetupType::Setup((root_setup, state)) => { + if let Some(v) = &state.config_opts.karg { + if v.len() > 0 { + tracing::warn!("kargs passed for UKI will be ignored"); + } + } + let esp_part = root_setup .device_info .partitions @@ -1753,7 +1895,11 @@ pub(crate) fn setup_composefs_uki_boot( .find(|p| p.parttype.as_str() == ESP_GUID) .ok_or_else(|| anyhow!("ESP partition not found"))?; - (root_setup.physical_root_path.clone(), esp_part.node.clone()) + ( + root_setup.physical_root_path.clone(), + esp_part.node.clone(), + state.composefs_options.as_ref().map(|x| x.insecure), + ) } BootSetupType::Upgrade => { @@ -1766,7 +1912,7 @@ pub(crate) fn setup_composefs_uki_boot( anyhow::bail!("Could not find parent device for mountpoint /sysroot"); }; - (sysroot, get_esp_partition(&parent)?.0) + (sysroot, get_esp_partition(&parent)?.0, None) } }; @@ -1779,16 +1925,66 @@ pub(crate) fn setup_composefs_uki_boot( .args([&PathBuf::from(&esp_device), &mounted_esp.clone()]) .run()?; - composefs_write_boot_simple( - &repo, - entry, - &id, - false, - &mounted_esp, - None, - Some(&id.to_hex()), - &[], - )?; + let boot_label = match entry { + ComposefsBootEntry::Type1(..) => todo!(), + ComposefsBootEntry::UsrLibModulesUki(..) => todo!(), + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => todo!(), + + ComposefsBootEntry::Type2(type2_entry) => { + let uki = read_file(&type2_entry.file, &repo).context("Reading UKI")?; + let cmdline = uki::get_cmdline(&uki).context("Getting UKI cmdline")?; + let (composefs_cmdline, insecure) = get_cmdline_composefs::(cmdline)?; + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + if let Some(is_insecure_from_opts) = is_insecure_from_opts { + match is_insecure_from_opts { + true => { + if !insecure { + tracing::warn!( + "--insecure passed as option but UKI cmdline does not support it" + ) + } + } + + false => { + if insecure { + tracing::warn!("UKI cmdline has composefs set as insecure") + } + } + } + } + + let boot_label = uki::get_boot_label(&uki).context("Getting UKI boot label")?; + + if composefs_cmdline != *id { + anyhow::bail!( + "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {id:?})" + ); + } + + // Write the UKI to ESP + let efi_linux_path = mounted_esp.join("EFI/Linux"); + create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?; + + let efi_linux = + cap_std::fs::Dir::open_ambient_dir(&efi_linux_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {efi_linux_path:?}"))?; + + efi_linux + .atomic_write(format!("{}.efi", id.to_hex()), uki) + .context("Writing UKI")?; + + rustix::fs::fsync( + efi_linux + .reopen_as_ownedfd() + .context("Reopening as owned fd")?, + ) + .context("fsync")?; + + boot_label + } + }; Task::new("Unmounting ESP", "umount") .arg(&mounted_esp) @@ -1809,66 +2005,75 @@ pub(crate) fn setup_composefs_uki_boot( let efi_uuid_source = get_efi_uuid_source(); let user_cfg_name = if is_upgrade { - "grub2/user.cfg.staged" + USER_CFG_STAGED } else { - "grub2/user.cfg" + USER_CFG }; - let user_cfg_path = boot_dir.join(user_cfg_name); + + let grub_dir = + cap_std::fs::Dir::open_ambient_dir(boot_dir.join("grub2"), cap_std::ambient_authority()) + .context("opening boot/grub2")?; // Iterate over all available deployments, and generate a menuentry for each // // TODO: We might find a staged deployment here if is_upgrade { - let mut usr_cfg = std::fs::OpenOptions::new() - .write(true) - .create(true) - .open(user_cfg_path) - .with_context(|| format!("Opening {user_cfg_name}"))?; + 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(), + )?; - usr_cfg.write_all(efi_uuid_source.as_bytes())?; - usr_cfg.write_all(get_user_config(&id.to_hex()).as_bytes())?; + let mut str_buf = String::new(); + let boot_dir = cap_std::fs::Dir::open_ambient_dir(boot_dir, cap_std::ambient_authority()) + .context("Opening boot dir")?; + let entries = get_sorted_uki_boot_entries(&boot_dir, &mut str_buf)?; - // root_path here will be /sysroot - for entry in std::fs::read_dir(root_path.join(STATE_DIR_RELATIVE))? { - let entry = entry?; + // 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())?; - 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(); + grub_dir + .atomic_write(user_cfg_name, buffer) + .with_context(|| format!("Writing to {user_cfg_name}"))?; - usr_cfg.write_all(get_user_config(&depl_file_name).as_bytes())?; - } + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; return Ok(()); } - let efi_uuid_file_path = format!("grub2/{EFI_UUID_FILE}"); - // 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 mut efi_uuid_file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .open(boot_dir.join(&efi_uuid_file_path)) - .with_context(|| format!("Opening {efi_uuid_file_path}"))?; - let esp_uuid = Task::new("blkid for ESP UUID", "blkid") .args(["-s", "UUID", "-o", "value", &esp_device]) .read()?; - efi_uuid_file - .write_all(format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes()) - .with_context(|| format!("Writing to {efi_uuid_file_path}"))?; + grub_dir.atomic_write( + EFI_UUID_FILE, + format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(), + )?; // Write to grub2/user.cfg - let mut usr_cfg = std::fs::OpenOptions::new() - .write(true) - .create(true) - .open(user_cfg_path) - .with_context(|| format!("Opening {user_cfg_name}"))?; + 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(), + )?; - usr_cfg.write_all(efi_uuid_source.as_bytes())?; - usr_cfg.write_all(get_user_config(&id.to_hex()).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(()) } @@ -1937,17 +2142,26 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - anyhow::bail!("No boot entries!"); }; - let Some(composefs_opts) = &state.composefs_options else { - anyhow::bail!("Could not find options for composefs") - }; + let boot_type = BootType::from(&entry); + let mut boot_digest: Option = None; - match composefs_opts.boot { + match boot_type { BootType::Bls => { - setup_composefs_bls_boot(BootSetupType::Setup(&root_setup), repo, &id, entry)? - } - BootType::Uki => { - setup_composefs_uki_boot(BootSetupType::Setup(&root_setup), repo, &id, entry)? + 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, + &id, + entry, + )?, }; write_composefs_state( @@ -1959,20 +2173,13 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - signature: None, }, false, - composefs_opts.boot, + 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( @@ -1981,17 +2188,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, @@ -2008,25 +2220,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 mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .open(COMPOSEFS_STAGED_DEPLOYMENT_PATH) - .context("Opening staged-deployment file")?; + 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}"))?; - file.write_all(deployment_id.to_hex().as_bytes())?; + 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/install/baseline.rs b/crates/lib/src/install/baseline.rs index cc5cd0cae..6bff7ee58 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -427,6 +427,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 ad3550ec8..66ce17516 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -6,6 +6,7 @@ mod boundimage; pub mod cli; +mod composefs_consts; pub(crate) mod deploy; pub(crate) mod fsck; pub(crate) mod generator; @@ -18,6 +19,7 @@ pub(crate) mod kargs; mod lints; mod lsm; pub(crate) mod metadata; +pub(crate) mod parsers; mod podman; mod progress_jsonl; mod reboot; @@ -27,7 +29,6 @@ mod status; mod store; mod task; mod utils; -mod bls_config; #[cfg(feature = "docgen")] mod docgen; 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 new file mode 100644 index 000000000..9e413c17f --- /dev/null +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -0,0 +1,269 @@ +use std::fmt::Display; + +use nom::{ + bytes::complete::{tag, take_until}, + character::complete::multispace0, + error::{Error, ErrorKind, ParseError}, + multi::many0, + sequence::{delimited, preceded}, + Err, IResult, Parser, +}; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct MenuentryBody<'a> { + pub(crate) insmod: Vec<&'a str>, + pub(crate) chainloader: String, + pub(crate) search: &'a str, + pub(crate) version: u8, + pub(crate) extra: Vec<(&'a str, &'a str)>, +} + +impl<'a> Display for MenuentryBody<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for insmod in &self.insmod { + writeln!(f, "insmod {}", insmod)?; + } + + writeln!(f, "search {}", self.search)?; + // writeln!(f, "version {}", self.version)?; + writeln!(f, "chainloader {}", self.chainloader)?; + + for (k, v) in &self.extra { + writeln!(f, "{k} {v}")?; + } + + Ok(()) + } +} + +impl<'a> From> for MenuentryBody<'a> { + fn from(vec: Vec<(&'a str, &'a str)>) -> Self { + let mut entry = Self { + insmod: vec![], + chainloader: "".into(), + search: "", + version: 0, + extra: vec![], + }; + + for (key, value) in vec { + match key { + "insmod" => entry.insmod.push(value), + "chainloader" => entry.chainloader = value.into(), + "search" => entry.search = value, + "set" => {} + _ => entry.extra.push((key, value)), + } + } + + return entry; + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct MenuEntry<'a> { + pub(crate) title: String, + pub(crate) body: MenuentryBody<'a>, +} + +impl<'a> Display for MenuEntry<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "menuentry \"{}\" {{", self.title)?; + write!(f, "{}", self.body)?; + writeln!(f, "}}") + } +} + +impl<'a> MenuEntry<'a> { + 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![], + }, + } + } +} + +pub fn take_until_balanced_allow_nested( + opening_bracket: char, + closing_bracket: char, +) -> impl Fn(&str) -> IResult<&str, &str> { + move |i: &str| { + let mut index = 0; + let mut bracket_counter = 0; + + while let Some(n) = &i[index..].find(&[opening_bracket, closing_bracket, '\\'][..]) { + index += n; + let mut characters = i[index..].chars(); + + match characters.next().unwrap_or_default() { + c if c == '\\' => { + // Skip '\' + index += '\\'.len_utf8(); + // Skip char following '\' + let c = characters.next().unwrap_or_default(); + index += c.len_utf8(); + } + + c if c == opening_bracket => { + bracket_counter += 1; + index += opening_bracket.len_utf8(); + } + + c if c == closing_bracket => { + bracket_counter -= 1; + index += closing_bracket.len_utf8(); + } + + // Should not happen + _ => unreachable!(), + }; + + // We found the unmatched closing bracket. + if bracket_counter == -1 { + // Don't consume it as we'll "tag" it afterwards + index -= closing_bracket.len_utf8(); + return Ok((&i[index..], &i[0..index])); + }; + } + + if bracket_counter == 0 { + Ok(("", i)) + } else { + Err(Err::Error(Error::from_error_kind(i, ErrorKind::TakeUntil))) + } + } +} + +fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry> { + let (input, _) = take_until("menuentry")(input)?; // skip irrelevant prefix + let (input, _) = tag("menuentry").parse(input)?; + + // Skip the whitespace after "menuentry" + let (input, _) = multispace0.parse(input)?; + // Eat up the title + let (input, title) = delimited(tag("\""), take_until("\""), tag("\"")).parse(input)?; + + // Skip any whitespace after title + let (input, _) = multispace0.parse(input)?; + + // Eat up everything insde { .. } + let (input, body) = delimited( + tag("{"), + take_until_balanced_allow_nested('{', '}'), + tag("}"), + ) + .parse(input)?; + + let mut map = vec![]; + + for line in body.lines() { + let line = line.trim(); + + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(' ') { + map.push((key, value.trim())); + } + } + + Ok(( + input, + MenuEntry { + title: title.to_string(), + body: MenuentryBody::from(map), + }, + )) +} + +#[rustfmt::skip] +fn parse_all(input: &str) -> IResult<&str, Vec> { + many0( + preceded( + multispace0, + parse_menuentry, + ) + ) + .parse(input) +} + +pub(crate) fn parse_grub_menuentry_file(contents: &str) -> anyhow::Result> { + let result = parse_all(&contents); + + return match result { + Ok((_, entries)) => Ok(entries), + Result::Err(_) => anyhow::bail!("Failed to parse grub menuentry"), + }; +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_menuconfig_parser() { + let menuentry = r#" + if [ -f ${config_directory}/efiuuid.cfg ]; then + source ${config_directory}/efiuuid.cfg + fi + + # Skip this comment + + menuentry "Fedora 42: (Verity-42)" { + insmod fat + insmod chain + # This should also be skipped + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi + } + + menuentry "Fedora 43: (Verity-43)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/uki.efi + extra_field1 this is extra + extra_field2 this is also extra + } + "#; + + let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries"); + + let expected = vec![ + MenuEntry { + title: "Fedora 42: (Verity-42)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + 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)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + 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") + ] + }, + }, + ]; + + println!("{}", expected[0]); + + assert_eq!(result, expected); + } +} diff --git a/crates/lib/src/parsers/mod.rs b/crates/lib/src/parsers/mod.rs new file mode 100644 index 000000000..e3640c8ef --- /dev/null +++ b/crates/lib/src/parsers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod bls_config; +pub(crate) mod grub_menuconfig; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 4d945a3ca..e3a8c12b3 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -165,7 +165,6 @@ pub struct BootEntryOstree { pub deploy_serial: u32, } - /// A bootable entry #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index dc1d966ad..7c5f876ca 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -14,7 +14,6 @@ use ostree::glib; use ostree_container::OstreeImageReference; use ostree_ext::container as ostree_container; use ostree_ext::container::deploy::ORIGIN_CONTAINER; -use ostree_ext::container_utils::composefs_booted; use ostree_ext::container_utils::ostree_booted; use ostree_ext::containers_image_proxy; use ostree_ext::keyfileext::KeyFileExt; @@ -24,15 +23,18 @@ 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_INSECURE_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}; use crate::store::{CachedImageStatus, ContainerImageStore, Storage}; +use crate::utils::composefs_booted; impl From for ImageSignature { fn from(sig: ostree_container::SignatureSource) -> Self { @@ -392,7 +394,11 @@ async fn boot_entry_from_composefs_deployment( #[context("Getting composefs deployment status")] pub(crate) async fn composefs_deployment_status() -> Result { let cmdline = crate::kernel::parse_cmdline()?; - let booted_image_verity = cmdline.iter().find_map(|x| x.strip_prefix("composefs=")); + + let booted_image_verity = cmdline.iter().find_map(|x| { + x.strip_prefix(COMPOSEFS_INSECURE_CMDLINE) + .or_else(|| x.strip_prefix(COMPOSEFS_CMDLINE)) + }); let Some(booted_image_verity) = booted_image_verity else { anyhow::bail!("Failed to find composefs parameter in kernel cmdline"); @@ -411,7 +417,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)?; @@ -422,6 +430,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?; @@ -441,6 +452,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); @@ -457,11 +483,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(booted_image_verity); + // 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(booted_image_verity); + } + + 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(booted_image_verity); + } + }; 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 a7ccb0687..96a0e8453 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; @@ -17,6 +18,14 @@ use ostree::glib; use ostree_ext::container::SignatureSource; use ostree_ext::ostree; +use crate::composefs_consts::{COMPOSEFS_CMDLINE, COMPOSEFS_INSECURE_CMDLINE}; + +/// Returns true if the system appears to have been booted with composefs without ostree. +pub fn composefs_booted() -> std::io::Result { + let cmdline = std::fs::read_to_string("/proc/cmdline")?; + Ok(cmdline.contains(COMPOSEFS_CMDLINE) || cmdline.contains(COMPOSEFS_INSECURE_CMDLINE)) +} + /// Try to look for keys injected by e.g. rpm-ostree requesting machine-local /// changes; if any are present, return `true`. pub(crate) fn origin_has_rpmostree_stuff(kf: &glib::KeyFile) -> bool { @@ -186,6 +195,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 +254,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" + ); + } } diff --git a/crates/ostree-ext/src/container_utils.rs b/crates/ostree-ext/src/container_utils.rs index 631a0c524..7737e986c 100644 --- a/crates/ostree-ext/src/container_utils.rs +++ b/crates/ostree-ext/src/container_utils.rs @@ -77,13 +77,6 @@ pub fn ostree_booted() -> io::Result { Path::new(&format!("/{OSTREE_BOOTED}")).try_exists() } - -/// Returns true if the system appears to have been booted with composefs. -pub fn composefs_booted() -> io::Result { - let cmdline = std::fs::read_to_string("/proc/cmdline")?; - Ok(cmdline.contains("composefs=")) -} - /// Returns true if the target root appears to have been booted via ostree. pub fn is_ostree_booted_in(rootfs: &Dir) -> io::Result { rootfs.try_exists(OSTREE_BOOTED)