diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index aa07bfe80..0f02198aa 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -16,20 +16,25 @@ pub(crate) fn install_via_bootupd( device: &PartitionTable, rootfs: &Utf8Path, configopts: &crate::install::InstallConfigOpts, - deployment_path: &str, + deployment_path: Option<&str>, ) -> Result<()> { let verbose = std::env::var_os("BOOTC_BOOTLOADER_DEBUG").map(|_| "-vvvv"); // bootc defaults to only targeting the platform boot method. let bootupd_opts = (!configopts.generic_image).then_some(["--update-firmware", "--auto"]); - let srcroot = rootfs.join(deployment_path); + let abs_deployment_path = deployment_path.map(|v| rootfs.join(v)); + let src_root_arg = if let Some(p) = abs_deployment_path.as_deref() { + vec!["--src-root", p.as_str()] + } else { + vec![] + }; let devpath = device.path(); println!("Installing bootloader via bootupd"); Command::new("bootupctl") .args(["backend", "install", "--write-uuid"]) .args(verbose) .args(bootupd_opts.iter().copied().flatten()) - .args(["--src-root", srcroot.as_str()]) + .args(src_root_arg) .args(["--device", devpath.as_str(), rootfs.as_str()]) .log_debug() .run_inherited_with_cmd_context() diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4aa032e55..e73036fb8 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -27,11 +27,16 @@ use ostree_ext::ostree; use schemars::schema_for; use serde::{Deserialize, Serialize}; -use crate::deploy::RequiredHostSpec; +use crate::deploy::{composefs_rollback, RequiredHostSpec}; +use crate::install::{ + pull_composefs_repo, setup_composefs_bls_boot, setup_composefs_uki_boot, write_composefs_state, + BootSetupType, BootType, +}; use crate::lints; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; use crate::spec::ImageReference; +use crate::status::{composefs_booted, composefs_deployment_status}; use crate::utils::sigpolicy_from_opt; /// Shared progress options @@ -778,6 +783,69 @@ fn prepare_for_write() -> Result<()> { Ok(()) } +#[context("Upgrading composefs")] +async fn upgrade_composefs(_opts: UpgradeOpts) -> Result<()> { + // TODO: IMPORTANT Have all the checks here that `bootc upgrade` has for an ostree booted system + + let host = composefs_deployment_status() + .await + .context("Getting composefs deployment status")?; + + // TODO: IMPORTANT We need to check if any deployment is staged and get the image from that + let imgref = host + .spec + .image + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + + // let booted_image = host + // .status + // .booted + // .ok_or(anyhow::anyhow!("Could not find booted image"))? + // .image + // .ok_or(anyhow::anyhow!("Could not find booted image"))?; + + // tracing::debug!("booted_image: {booted_image:#?}"); + // tracing::debug!("imgref: {imgref:#?}"); + + // let digest = booted_image + // .digest() + // .context("Getting digest for booted image")?; + + let (repo, entries, id) = pull_composefs_repo(&imgref.transport, &imgref.image).await?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(&entry); + let mut boot_digest = None; + + match boot_type { + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade, + repo, + &id, + entry, + )?) + } + + 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(()) +} + /// Implementation of the `bootc upgrade` CLI command. #[context("Upgrading")] async fn upgrade(opts: UpgradeOpts) -> Result<()> { @@ -891,9 +959,7 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { Ok(()) } -/// Implementation of the `bootc switch` CLI command. -#[context("Switching")] -async fn switch(opts: SwitchOpts) -> Result<()> { +fn imgref_for_switch(opts: &SwitchOpts) -> Result { let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; let imgref = ostree_container::ImageReference { transport, @@ -902,6 +968,72 @@ async fn switch(opts: SwitchOpts) -> Result<()> { let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy); let target = ostree_container::OstreeImageReference { sigverify, imgref }; let target = ImageReference::from(target); + + return Ok(target); +} + +#[context("Composefs Switching")] +async fn switch_composefs(opts: SwitchOpts) -> Result<()> { + let target = imgref_for_switch(&opts)?; + // TODO: Handle in-place + + let host = composefs_deployment_status() + .await + .context("Getting composefs deployment status")?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.image = Some(target.clone()); + new_spec + }; + + if new_spec == host.spec { + println!("Image specification is unchanged."); + return Ok(()); + } + + let Some(target_imgref) = new_spec.image else { + anyhow::bail!("Target image is undefined") + }; + + let (repo, entries, id) = pull_composefs_repo(&"docker".into(), &target_imgref.image).await?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(&entry); + let mut boot_digest = None; + + match boot_type { + BootType::Bls => { + boot_digest = Some(setup_composefs_bls_boot( + BootSetupType::Upgrade, + repo, + &id, + entry, + )?) + } + BootType::Uki => setup_composefs_uki_boot(BootSetupType::Upgrade, repo, &id, entry)?, + }; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + &target_imgref, + true, + boot_type, + boot_digest, + )?; + + Ok(()) +} + +/// Implementation of the `bootc switch` CLI command. +#[context("Switching")] +async fn switch(opts: SwitchOpts) -> Result<()> { + let target = imgref_for_switch(&opts)?; + let prog: ProgressWriter = opts.progress.try_into()?; // If we're doing an in-place mutation, we shortcut most of the rest of the work here @@ -966,8 +1098,12 @@ async fn switch(opts: SwitchOpts) -> Result<()> { /// Implementation of the `bootc rollback` CLI command. #[context("Rollback")] async fn rollback(opts: RollbackOpts) -> Result<()> { - let sysroot = &get_storage().await?; - crate::deploy::rollback(sysroot).await?; + if composefs_booted()?.is_some() { + composefs_rollback().await? + } else { + let sysroot = &get_storage().await?; + crate::deploy::rollback(sysroot).await?; + }; if opts.apply { crate::reboot::reboot()?; @@ -1117,8 +1253,20 @@ impl Opt { async fn run_from_opt(opt: Opt) -> Result<()> { let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; match opt { - Opt::Upgrade(opts) => upgrade(opts).await, - Opt::Switch(opts) => switch(opts).await, + Opt::Upgrade(opts) => { + if composefs_booted()?.is_some() { + upgrade_composefs(opts).await + } else { + upgrade(opts).await + } + } + Opt::Switch(opts) => { + if composefs_booted()?.is_some() { + switch_composefs(opts).await + } else { + switch(opts).await + } + } Opt::Rollback(opts) => rollback(opts).await, Opt::Edit(opts) => edit(opts).await, Opt::UsrOverlay => usroverlay().await, @@ -1259,8 +1407,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { FsverityOpts::Enable { path } => { let fd = std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?; - // Note this is not robust to forks, we're not using the _maybe_copy variant - fsverity::enable_verity_with_retry::(&fd)?; + fsverity::enable_verity_raw::(&fd)?; Ok(()) } }, 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 b003e70f7..20d31e4b4 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -3,7 +3,10 @@ //! Create a merged filesystem tree with the image and mounted configmaps. use std::collections::HashSet; -use std::io::{BufRead, Write}; +use std::fmt::Write as _; +use std::fs::create_dir_all; +use std::io::{BufRead, Read, Write}; +use std::path::PathBuf; use anyhow::Ok; use anyhow::{anyhow, Context, Result}; @@ -20,11 +23,18 @@ use ostree_ext::ostree::Deployment; use ostree_ext::ostree::{self, Sysroot}; use ostree_ext::sysroot::SysrootLock; use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; - +use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; + +use crate::composefs_consts::{ + BOOT_LOADER_ENTRIES, ROLLBACK_BOOT_LOADER_ENTRIES, USER_CFG, USER_CFG_ROLLBACK, +}; +use crate::install::{get_efi_uuid_source, BootType}; +use crate::parsers::bls_config::{parse_bls_config, BLSConfig}; +use crate::parsers::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; use crate::spec::ImageReference; use crate::spec::{BootOrder, HostSpec}; -use crate::status::labels_of_config; +use crate::status::{composefs_deployment_status, labels_of_config}; use crate::store::Storage; use crate::utils::async_task_with_spinner; @@ -737,6 +747,232 @@ pub(crate) async fn stage( Ok(()) } +#[context("Rolling back UKI")] +pub(crate) fn rollback_composefs_uki() -> Result<()> { + let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); + + let mut str = String::new(); + let boot_dir = + cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) + .context("Opening boot dir")?; + let mut menuentries = + get_sorted_uki_boot_entries(&boot_dir, &mut str).context("Getting UKI boot entries")?; + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(menuentries.len() == 2); + + let (first, second) = menuentries.split_at_mut(1); + std::mem::swap(&mut first[0], &mut second[0]); + + let mut buffer = get_efi_uuid_source(); + + for entry in menuentries { + write!(buffer, "{entry}")?; + } + + let entries_dir = + cap_std::fs::Dir::open_ambient_dir(&user_cfg_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {user_cfg_path:?}"))?; + + entries_dir + .atomic_write(USER_CFG_ROLLBACK, buffer) + .with_context(|| format!("Writing to {USER_CFG_ROLLBACK}"))?; + + tracing::debug!("Atomically exchanging for {USER_CFG_ROLLBACK} and {USER_CFG}"); + renameat_with( + &entries_dir, + USER_CFG_ROLLBACK, + &entries_dir, + USER_CFG, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {USER_CFG_ROLLBACK}"); + rustix::fs::unlinkat(&entries_dir, USER_CFG_ROLLBACK, AtFlags::empty()).context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + fsync( + entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening {user_cfg_path:?} as owned fd"))?, + ) + .with_context(|| format!("fsync {user_cfg_path:?}"))?; + + Ok(()) +} + +// Need str to store lifetime +pub(crate) fn get_sorted_uki_boot_entries<'a>( + boot_dir: &Dir, + str: &'a mut String, +) -> Result>> { + let mut file = boot_dir + .open(format!("grub2/{USER_CFG}")) + .with_context(|| format!("Opening {USER_CFG}"))?; + file.read_to_string(str)?; + parse_grub_menuentry_file(str) +} + +#[context("Getting sorted BLS entries")] +pub(crate) fn get_sorted_bls_boot_entries( + boot_dir: &Dir, + ascending: bool, +) -> Result> { + let mut all_configs = vec![]; + + for entry in boot_dir.read_dir(format!("loader/{BOOT_LOADER_ENTRIES}"))? { + let entry = entry?; + + let file_name = entry.file_name(); + + let file_name = file_name + .to_str() + .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?; + + if !file_name.ends_with(".conf") { + continue; + } + + let mut file = entry + .open() + .with_context(|| format!("Failed to open {:?}", file_name))?; + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .with_context(|| format!("Failed to read {:?}", file_name))?; + + let config = parse_bls_config(&contents).context("Parsing bls config")?; + + all_configs.push(config); + } + + all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); + + return Ok(all_configs); +} + +#[context("Rolling back BLS")] +pub(crate) fn rollback_composefs_bls() -> Result<()> { + let boot_dir = + cap_std::fs::Dir::open_ambient_dir("/sysroot/boot", cap_std::ambient_authority()) + .context("Opening boot dir")?; + + // Sort in descending order as that's the order they're shown on the boot screen + // After this: + // all_configs[0] -> booted depl + // all_configs[1] -> rollback depl + let mut all_configs = get_sorted_bls_boot_entries(&boot_dir, false)?; + + // Update the indicies so that they're swapped + for (idx, cfg) in all_configs.iter_mut().enumerate() { + cfg.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_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); + + rollback_entries_dir + .atomic_write(&file_name, cfg.to_string()) + .with_context(|| format!("Writing to {file_name}"))?; + } + + // Should we sync after every write? + fsync( + rollback_entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening {dir_path:?} as owned fd"))?, + ) + .with_context(|| format!("fsync {dir_path:?}"))?; + + // Atomically exchange "entries" <-> "entries.rollback" + let dir = Dir::open_ambient_dir("/sysroot/boot/loader", cap_std::ambient_authority()) + .context("Opening loader dir")?; + + tracing::debug!( + "Atomically exchanging for {ROLLBACK_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}" + ); + renameat_with( + &dir, + ROLLBACK_BOOT_LOADER_ENTRIES, + &dir, + BOOT_LOADER_ENTRIES, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {ROLLBACK_BOOT_LOADER_ENTRIES}"); + rustix::fs::unlinkat(&dir, ROLLBACK_BOOT_LOADER_ENTRIES, AtFlags::empty()) + .context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + fsync( + dir.reopen_as_ownedfd() + .with_context(|| format!("Reopening /sysroot/boot/loader as owned fd"))?, + ) + .context("fsync")?; + + Ok(()) +} + +#[context("Rolling back composefs")] +pub(crate) async fn composefs_rollback() -> Result<()> { + let host = composefs_deployment_status().await?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.boot_order = new_spec.boot_order.swap(); + new_spec + }; + + // Just to be sure + host.spec.verify_transition(&new_spec)?; + + let reverting = new_spec.boot_order == BootOrder::Default; + if reverting { + println!("notice: Reverting queued rollback state"); + } + + let rollback_status = host + .status + .rollback + .ok_or_else(|| anyhow!("No rollback available"))?; + + // TODO: Handle staged deployment + // Ostree will drop any staged deployment on rollback but will keep it if it is the first item + // in the new deployment list + let Some(rollback_composefs_entry) = &rollback_status.composefs else { + anyhow::bail!("Rollback deployment not a composefs deployment") + }; + + match rollback_composefs_entry.boot_type { + BootType::Bls => rollback_composefs_bls(), + BootType::Uki => rollback_composefs_uki(), + }?; + + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + + Ok(()) +} + /// Implementation of rollback functionality pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> { const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; @@ -946,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] @@ -1040,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 bada00301..8116db76b 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -14,16 +14,19 @@ mod osbuild; pub(crate) mod osconfig; use std::collections::HashMap; +use std::fs::create_dir_all; use std::io::Write; use std::os::fd::{AsFd, AsRawFd}; +use std::os::unix::fs::symlink; use std::os::unix::process::CommandExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, ensure, Context, Result}; +use bootc_blockdev::{find_parent_devices, PartitionTable}; use bootc_utils::CommandRunExt; use camino::Utf8Path; use camino::Utf8PathBuf; @@ -37,32 +40,61 @@ use cap_std_ext::cmdext::CapStdExtCommandExt; use cap_std_ext::prelude::CapStdExtDirExt; use chrono::prelude::*; use clap::ValueEnum; +use composefs_boot::bootloader::read_file; use fn_error_context::context; use ostree::gio; +use ostree_ext::composefs::{ + fsverity::{FsVerityHashValue, Sha256HashValue}, + repository::Repository as ComposefsRepository, + util::Sha256Digest, +}; +use ostree_ext::composefs_boot::bootloader::UsrLibModulesVmlinuz; +use ostree_ext::composefs_boot::{ + bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, uki, BootOps, +}; +use ostree_ext::composefs_oci::{ + image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, +}; +use ostree_ext::container::deploy::ORIGIN_CONTAINER; use ostree_ext::oci_spec; use ostree_ext::ostree; use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate}; use ostree_ext::prelude::Cast; use ostree_ext::sysroot::SysrootLock; -use ostree_ext::{container as ostree_container, ostree_prepareroot}; +use ostree_ext::{ + container as ostree_container, container::ImageReference as OstreeExtImgRef, ostree_prepareroot, +}; #[cfg(feature = "install-to-disk")] use rustix::fs::FileTypeExt; use rustix::fs::MetadataExt as _; +use rustix::path::Arg; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[cfg(feature = "install-to-disk")] use self::baseline::InstallBlockDeviceOpts; use crate::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 bootc_mount::Filesystem; +use crate::utils::{path_relative_to, sigpolicy_from_opt}; +use bootc_mount::{inspect_filesystem, Filesystem}; /// The toplevel boot directory const BOOT: &str = "boot"; @@ -82,6 +114,8 @@ const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi const EFIVARFS: &str = "/sys/firmware/efi/efivars"; pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); +pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; +pub(crate) const DPS_UUID: &str = "6523f8ae-3eb1-4e2a-a05a-18b695ae656f"; const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ // Default to avoiding grub2-mkconfig etc. @@ -188,7 +222,7 @@ pub(crate) struct InstallConfigOpts { /// /// Example: --karg=nosmt --karg=console=ttyS0,114800n8 #[clap(long)] - karg: Option>, + pub(crate) karg: Option>, /// The path to an `authorized_keys` that will be injected into the `root` account. /// @@ -220,6 +254,58 @@ pub(crate) struct InstallConfigOpts { pub(crate) stateroot: Option, } +#[derive( + ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema, +)] +pub enum BootType { + #[default] + Bls, + Uki, +} + +impl ::std::fmt::Display for BootType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + BootType::Bls => "bls", + BootType::Uki => "uki", + }; + + write!(f, "{}", s) + } +} + +impl TryFrom<&str> for BootType { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + match value { + "bls" => Ok(Self::Bls), + "uki" => Ok(Self::Uki), + unrecognized => Err(anyhow::anyhow!( + "Unrecognized boot option: '{unrecognized}'" + )), + } + } +} + +impl From<&ComposefsBootEntry> for BootType { + fn from(entry: &ComposefsBootEntry) -> Self { + match entry { + ComposefsBootEntry::Type1(..) => Self::Bls, + ComposefsBootEntry::Type2(..) => Self::Uki, + ComposefsBootEntry::UsrLibModulesUki(..) => Self::Uki, + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + } + } +} + +#[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct InstallComposefsOpts { + #[clap(long, default_value_t)] + #[serde(default)] + pub(crate) insecure: bool, +} + #[cfg(feature = "install-to-disk")] #[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallToDiskOpts { @@ -243,6 +329,14 @@ pub(crate) struct InstallToDiskOpts { #[clap(long)] #[serde(default)] pub(crate) via_loopback: bool, + + #[clap(long)] + #[serde(default)] + pub(crate) composefs_native: bool, + + #[clap(flatten)] + #[serde(flatten)] + pub(crate) composefs_opts: InstallComposefsOpts, } #[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -365,6 +459,7 @@ pub(crate) struct SourceInfo { } // Shared read-only global state +#[derive(Debug)] pub(crate) struct State { pub(crate) source: SourceInfo, /// Force SELinux off in target system @@ -382,6 +477,9 @@ pub(crate) struct State { /// The root filesystem of the running container pub(crate) container_root: Dir, pub(crate) tempdir: TempDir, + + // If Some, then --composefs_native is passed + pub(crate) composefs_options: Option, } impl State { @@ -529,6 +627,20 @@ impl FromStr for MountSpec { } } +#[cfg(feature = "install-to-disk")] +impl InstallToDiskOpts { + pub(crate) fn validate(&self) -> Result<()> { + if !self.composefs_native { + // Reject using --insecure without --composefs + if self.composefs_opts.insecure != false { + anyhow::bail!("--insecure must not be provided without --composefs"); + } + } + + Ok(()) + } +} + impl InstallAleph { #[context("Creating aleph data")] pub(crate) fn new( @@ -1180,6 +1292,7 @@ async fn prepare_install( config_opts: InstallConfigOpts, source_opts: InstallSourceOpts, target_opts: InstallTargetOpts, + composefs_opts: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1324,6 +1437,7 @@ async fn prepare_install( container_root: rootfs, tempdir, host_is_container, + composefs_options: composefs_opts, }); Ok(state) @@ -1363,7 +1477,7 @@ async fn install_with_sysroot( &rootfs.device_info, &rootfs.physical_root_path, &state.config_opts, - &deployment_path.as_str(), + Some(&deployment_path.as_str()), )?; } tracing::debug!("Installed bootloader"); @@ -1425,6 +1539,713 @@ impl BoundImages { } } +pub(crate) fn open_composefs_repo( + rootfs_dir: &Dir, +) -> Result> { + ComposefsRepository::open_path(rootfs_dir, "composefs") + .context("Failed to open composefs repository") +} + +async fn initialize_composefs_repository( + state: &State, + root_setup: &RootSetup, +) -> Result<(Sha256Digest, impl FsVerityHashValue)> { + let rootfs_dir = &root_setup.physical_root; + + rootfs_dir + .create_dir_all("composefs") + .context("Creating dir composefs")?; + + let repo = open_composefs_repo(rootfs_dir)?; + + let OstreeExtImgRef { + name: image_name, + transport, + } = &state.source.imageref; + + // transport's display is already of type ":" + composefs_oci_pull(&Arc::new(repo), &format!("{transport}{image_name}"), None).await +} + +fn get_booted_bls() -> Result { + let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; + let booted = cmdline + .find_str(COMPOSEFS_CMDLINE) + .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; + + for entry in std::fs::read_dir("/sysroot/boot/loader/entries")? { + let entry = entry?; + + if !entry.file_name().as_str()?.ends_with(".conf") { + continue; + } + + let bls = parse_bls_config(&std::fs::read_to_string(&entry.path())?)?; + + // TODO clean this up + if bls.options.contains(booted.as_ref()) { + return Ok(bls); + } + } + + Err(anyhow::anyhow!("Booted BLS not found")) +} + +pub(crate) enum BootSetupType<'a> { + /// For initial setup, i.e. install to-disk + Setup((&'a RootSetup, &'a State)), + /// 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, + // TODO: Make this generic + repo: ComposefsRepository, + id: &Sha256HashValue, + entry: ComposefsBootEntry, +) -> Result { + let id_hex = id.to_hex(); + + let (root_path, cmdline_refs) = match setup_type { + BootSetupType::Setup((root_setup, state)) => { + // root_setup.kargs has [root=UUID=, "rw"] + let mut cmdline_options = String::from(root_setup.kargs.join(" ")); + + match &state.composefs_options { + Some(opt) if opt.insecure => { + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}=?{id_hex}")); + } + None | Some(..) => { + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}={id_hex}")); + } + }; + + (root_setup.physical_root_path.clone(), cmdline_options) + } + + BootSetupType::Upgrade => ( + Utf8PathBuf::from("/sysroot"), + vec![ + format!("root=UUID={DPS_UUID}"), + RW_KARG.to_string(), + format!("{COMPOSEFS_CMDLINE}={id_hex}"), + ] + .join(" "), + ), + }; + + let boot_dir = root_path.join("boot"); + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade); + + let (bls_config, boot_digest) = match &entry { + ComposefsBootEntry::Type1(..) => unimplemented!(), + ComposefsBootEntry::Type2(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), + + ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { + let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) + .context("Computing boot digest")?; + + 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 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(format!("loader/{STAGED_BOOT_LOADER_ENTRIES}")), + Some(booted_bls), + ) + } else { + (boot_dir.join(format!("loader/{BOOT_LOADER_ENTRIES}")), None) + }; + + create_dir_all(&entries_path).with_context(|| format!("Creating {:?}", entries_path))?; + + 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 { + loader_entries_dir.atomic_write( + format!("bootc-composefs-{}.conf", booted_bls.version), + booted_bls.to_string().as_bytes(), + )?; + } + + let owned_loader_entries_fd = loader_entries_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?; + rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?; + + Ok(boot_digest) +} + +pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { + let device_info: PartitionTable = bootc_blockdev::partitions_of(Utf8Path::new(device))?; + let esp = device_info + .partitions + .into_iter() + .find(|p| p.parttype.as_str() == ESP_GUID) + .ok_or(anyhow::anyhow!("ESP not found for device: {device}"))?; + + Ok((esp.node, esp.uuid)) +} + +/// Contains the EFP's filesystem UUID. Used by grub +pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; + +/// Returns the beginning of the grub2/user.cfg file +/// where we source a file containing the ESPs filesystem UUID +pub(crate) fn get_efi_uuid_source() -> String { + format!( + r#" +if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then + source ${{config_directory}}/{EFI_UUID_FILE} +fi +"# + ) +} + +#[context("Setting up UKI boot")] +pub(crate) fn setup_composefs_uki_boot( + setup_type: BootSetupType, + // TODO: Make this generic + repo: ComposefsRepository, + id: &Sha256HashValue, + entry: ComposefsBootEntry, +) -> Result<()> { + let (root_path, esp_device, is_insecure_from_opts) = match setup_type { + BootSetupType::Setup((root_setup, state)) => { + if let Some(v) = &state.config_opts.karg { + if v.len() > 0 { + tracing::warn!("kargs passed for UKI will be ignored"); + } + } + + let esp_part = root_setup + .device_info + .partitions + .iter() + .find(|p| p.parttype.as_str() == ESP_GUID) + .ok_or_else(|| anyhow!("ESP partition not found"))?; + + ( + root_setup.physical_root_path.clone(), + esp_part.node.clone(), + state.composefs_options.as_ref().map(|x| x.insecure), + ) + } + + BootSetupType::Upgrade => { + let sysroot = Utf8PathBuf::from("/sysroot"); + + let fsinfo = inspect_filesystem(&sysroot)?; + let parent_devices = find_parent_devices(&fsinfo.source)?; + + let Some(parent) = parent_devices.into_iter().next() else { + anyhow::bail!("Could not find parent device for mountpoint /sysroot"); + }; + + (sysroot, get_esp_partition(&parent)?.0, None) + } + }; + + let mounted_esp: PathBuf = root_path.join("esp").into(); + let esp_mount_point_existed = mounted_esp.exists(); + + create_dir_all(&mounted_esp).context("Failed to create dir {mounted_esp:?}")?; + + Task::new("Mounting ESP", "mount") + .args([&PathBuf::from(&esp_device), &mounted_esp.clone()]) + .run()?; + + let boot_label = match entry { + ComposefsBootEntry::Type1(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesVmLinuz(..) => unimplemented!(), + + ComposefsBootEntry::Type2(type2_entry) => { + let uki = read_file(&type2_entry.file, &repo).context("Reading UKI")?; + let cmdline = uki::get_cmdline(&uki).context("Getting UKI cmdline")?; + let (composefs_cmdline, insecure) = get_cmdline_composefs::(cmdline)?; + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + if let Some(is_insecure_from_opts) = is_insecure_from_opts { + match is_insecure_from_opts { + true => { + if !insecure { + tracing::warn!( + "--insecure passed as option but UKI cmdline does not support it" + ) + } + } + + false => { + if insecure { + tracing::warn!("UKI cmdline has composefs set as insecure") + } + } + } + } + + let boot_label = uki::get_boot_label(&uki).context("Getting UKI boot label")?; + + if composefs_cmdline != *id { + anyhow::bail!( + "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {id:?})" + ); + } + + // Write the UKI to ESP + let efi_linux_path = mounted_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) + .run()?; + + if !esp_mount_point_existed { + // This shouldn't be a fatal error + if let Err(e) = std::fs::remove_dir(&mounted_esp) { + tracing::error!("Failed to remove mount point '{mounted_esp:?}': {e}"); + } + } + + let boot_dir = root_path.join("boot"); + create_dir_all(&boot_dir).context("Failed to create boot dir")?; + + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade); + + let efi_uuid_source = get_efi_uuid_source(); + + let user_cfg_name = if is_upgrade { + USER_CFG_STAGED + } else { + USER_CFG + }; + + let grub_dir = + cap_std::fs::Dir::open_ambient_dir(boot_dir.join("grub2"), cap_std::ambient_authority()) + .context("opening boot/grub2")?; + + // Iterate over all available deployments, and generate a menuentry for each + // + // TODO: We might find a staged deployment here + if is_upgrade { + let mut buffer = vec![]; + + // Shouldn't really fail so no context here + buffer.write_all(efi_uuid_source.as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + let mut str_buf = String::new(); + let boot_dir = cap_std::fs::Dir::open_ambient_dir(boot_dir, cap_std::ambient_authority()) + .context("Opening boot dir")?; + let entries = get_sorted_uki_boot_entries(&boot_dir, &mut str_buf)?; + + // Write out only the currently booted entry, which should be the very first one + // Even if we have booted into the second menuentry "boot entry", the default will be the + // first one + buffer.write_all(entries[0].to_string().as_bytes())?; + + grub_dir + .atomic_write(user_cfg_name, buffer) + .with_context(|| format!("Writing to {user_cfg_name}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; + + return Ok(()); + } + + // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there + // This will be sourced by grub2/user.cfg to be used for `--fs-uuid` + let esp_uuid = Task::new("blkid for ESP UUID", "blkid") + .args(["-s", "UUID", "-o", "value", &esp_device]) + .read()?; + + grub_dir.atomic_write( + EFI_UUID_FILE, + format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(), + )?; + + // Write to grub2/user.cfg + let mut buffer = vec![]; + + // Shouldn't really fail so no context here + buffer.write_all(efi_uuid_source.as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + grub_dir + .atomic_write(user_cfg_name, buffer) + .with_context(|| format!("Writing to {user_cfg_name}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; + + Ok(()) +} + +/// Pulls the `image` from `transport` into a composefs repository at /sysroot +/// Checks for boot entries in the image and returns them +#[context("Pulling composefs repository")] +pub(crate) async fn pull_composefs_repo( + transport: &String, + image: &String, +) -> Result<( + ComposefsRepository, + Vec>, + Sha256HashValue, +)> { + let rootfs_dir = cap_std::fs::Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())?; + + let repo = open_composefs_repo(&rootfs_dir).context("Opening compoesfs repo")?; + + let (id, verity) = composefs_oci_pull(&Arc::new(repo), &format!("{transport}:{image}"), None) + .await + .context("Pulling composefs repo")?; + + tracing::debug!( + "id = {id}, verity = {verity}", + id = hex::encode(id), + verity = verity.to_hex() + ); + + let repo = open_composefs_repo(&rootfs_dir)?; + let mut fs = create_composefs_filesystem(&repo, &hex::encode(id), None) + .context("Failed to create composefs filesystem")?; + + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + + Ok((repo, entries, id)) +} + +#[context("Setting up composefs boot")] +fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) -> Result<()> { + let boot_uuid = root_setup + .get_boot_uuid()? + .or(root_setup.rootfs_uuid.as_deref()) + .ok_or_else(|| anyhow!("No uuid for boot/root"))?; + + if cfg!(target_arch = "s390x") { + // TODO: Integrate s390x support into install_via_bootupd + crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?; + } else { + crate::bootloader::install_via_bootupd( + &root_setup.device_info, + &root_setup.physical_root_path, + &state.config_opts, + None, + )?; + } + + let repo = open_composefs_repo(&root_setup.physical_root)?; + + let mut fs = create_composefs_filesystem(&repo, image_id, None)?; + + let entries = fs.transform_for_boot(&repo)?; + let id = fs.commit_image(&repo, None)?; + + let Some(entry) = entries.into_iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(&entry); + let mut boot_digest: Option = None; + + match boot_type { + BootType::Bls => { + let digest = setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state)), + repo, + &id, + entry, + )?; + + boot_digest = Some(digest); + } + BootType::Uki => setup_composefs_uki_boot( + BootSetupType::Setup((&root_setup, &state)), + repo, + &id, + entry, + )?, + }; + + write_composefs_state( + &root_setup.physical_root_path, + id, + &ImageReference { + image: state.source.imageref.name.clone(), + transport: state.source.imageref.transport.to_string(), + signature: None, + }, + false, + boot_type, + boot_digest, + )?; + + Ok(()) +} + +/// Creates and populates /sysroot/state/deploy/image_id +#[context("Writing composefs state")] +pub(crate) fn write_composefs_state( + root_path: &Utf8PathBuf, + deployment_id: Sha256HashValue, + imgref: &ImageReference, + staged: bool, + boot_type: BootType, + boot_digest: Option, +) -> Result<()> { + let state_path = root_path.join(format!("{STATE_DIR_RELATIVE}/{}", deployment_id.to_hex())); + + create_dir_all(state_path.join("etc/upper"))?; + create_dir_all(state_path.join("etc/work"))?; + + let actual_var_path = root_path.join(SHARED_VAR_PATH); + create_dir_all(&actual_var_path)?; + + symlink( + path_relative_to(state_path.as_std_path(), actual_var_path.as_std_path()) + .context("Getting var symlink path")?, + state_path.join("var"), + ) + .context("Failed to create symlink for /var")?; + + let ImageReference { + image: image_name, + transport, + .. + } = &imgref; + + let mut config = tini::Ini::new().section("origin").item( + ORIGIN_CONTAINER, + format!("ostree-unverified-image:{transport}{image_name}"), + ); + + config = config + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_TYPE, boot_type); + + if let Some(boot_digest) = boot_digest { + config = config + .section(ORIGIN_KEY_BOOT) + .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest); + } + + let state_dir = cap_std::fs::Dir::open_ambient_dir(&state_path, cap_std::ambient_authority()) + .context("Opening state dir")?; + + state_dir + .atomic_write( + format!("{}.origin", deployment_id.to_hex()), + config.to_string().as_bytes(), + ) + .context("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 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(()) +} + async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, @@ -1457,34 +2278,47 @@ async fn install_to_filesystem_impl( let bound_images = BoundImages::from_state(state).await?; - // Initialize the ostree sysroot (repo, stateroot, etc.) + if state.composefs_options.is_some() { + // Load a fd for the mounted target physical root + let (id, verity) = initialize_composefs_repository(state, rootfs).await?; - { - let (sysroot, has_ostree, imgstore) = initialize_ostree_root(state, rootfs).await?; - - install_with_sysroot( - state, - rootfs, - &sysroot, - &boot_uuid, - bound_images, - has_ostree, - &imgstore, - ) - .await?; + tracing::warn!( + "id = {id}, verity = {verity}", + id = hex::encode(id), + verity = verity.to_hex() + ); - if matches!(cleanup, Cleanup::TriggerOnNextBoot) { - let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?; - tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}"); - sysroot_dir.atomic_write(format!("etc/{}", DESTRUCTIVE_CLEANUP), b"")?; - } + setup_composefs_boot(rootfs, state, &hex::encode(id))?; + } else { + // Initialize the ostree sysroot (repo, stateroot, etc.) + + { + let (sysroot, has_ostree, imgstore) = initialize_ostree_root(state, rootfs).await?; + + install_with_sysroot( + state, + rootfs, + &sysroot, + &boot_uuid, + bound_images, + has_ostree, + &imgstore, + ) + .await?; - // We must drop the sysroot here in order to close any open file - // descriptors. - }; + if matches!(cleanup, Cleanup::TriggerOnNextBoot) { + let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?; + tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}"); + sysroot_dir.atomic_write(format!("etc/{}", DESTRUCTIVE_CLEANUP), b"")?; + } - // Run this on every install as the penultimate step - install_finalize(&rootfs.physical_root_path).await?; + // We must drop the sysroot here in order to close any open file + // descriptors. + }; + + // Run this on every install as the penultimate step + install_finalize(&rootfs.physical_root_path).await?; + } // Finalize mounted filesystems if !rootfs.skip_finalize { @@ -1505,6 +2339,8 @@ fn installation_complete() { #[context("Installing to disk")] #[cfg(feature = "install-to-disk")] pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { + opts.validate()?; + let mut block_opts = opts.block_opts; let target_blockdev_meta = block_opts .device @@ -1526,7 +2362,17 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { } else if !target_blockdev_meta.file_type().is_block_device() { anyhow::bail!("Not a block device: {}", block_opts.device); } - let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts).await?; + let state = prepare_install( + opts.config_opts, + opts.source_opts, + opts.target_opts, + if opts.composefs_native { + Some(opts.composefs_opts) + } else { + None + }, + ) + .await?; // This is all blocking stuff let (mut rootfs, loopback) = { @@ -1735,7 +2581,7 @@ pub(crate) async fn install_to_filesystem( // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. // IMPORTANT: In practice, we should only be gathering information before this point, // IMPORTANT: and not performing any mutations at all. - let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts).await?; + let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts, None).await?; // And the last bit of state here is the fsopts, which we also destructure now. let mut fsopts = opts.filesystem_opts; diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 1cd48c48f..e5f30ca1c 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -42,8 +42,6 @@ pub(crate) const LINUX_PARTTYPE: &str = "0FC63DAF-8483-4772-8E79-3D69D8477DE4"; pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; #[cfg(feature = "install-to-disk")] pub(crate) const PREPBOOT_LABEL: &str = "PowerPC-PReP-boot"; -#[cfg(feature = "install-to-disk")] -pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; #[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -106,10 +104,15 @@ fn mkfs<'a>( label: &str, wipe: bool, opts: impl IntoIterator, + dps_uuid: Option, ) -> Result { let devinfo = bootc_blockdev::list_dev(dev.into())?; let size = ostree_ext::glib::format_size(devinfo.size); - let u = uuid::Uuid::new_v4(); + let u = if let Some(u) = dps_uuid { + u + } else { + uuid::Uuid::new_v4() + }; let mut t = Task::new( &format!("Creating {label} filesystem ({fs}) on device {dev} (size={size})"), format!("mkfs.{fs}"), @@ -275,7 +278,7 @@ pub(crate) fn install_create_rootfs( } let esp_partno = if super::ARCH_USES_EFI { - let esp_guid = ESP_GUID; + let esp_guid = crate::install::ESP_GUID; partno += 1; writeln!( &mut partitioning_buf, @@ -383,6 +386,7 @@ pub(crate) fn install_create_rootfs( "boot", opts.wipe, [], + None, ) .context("Initializing /boot")?, ) @@ -403,6 +407,8 @@ pub(crate) fn install_create_rootfs( "root", opts.wipe, mkfs_options.iter().copied(), + // TODO: Add cli option for this + Some(uuid::uuid!(crate::install::DPS_UUID)), )?; let rootarg = format!("root=UUID={root_uuid}"); let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); @@ -418,6 +424,7 @@ pub(crate) fn install_create_rootfs( .flatten() .chain([rootarg, RW_KARG.to_string()].into_iter()) .chain(bootarg) + .chain(state.config_opts.karg.clone().into_iter().flatten()) .collect::>(); bootc_mount::mount(&rootdev, &physical_root_path)?; diff --git a/crates/lib/src/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 a60d0bc30..a7c02391d 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -8,6 +8,7 @@ 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/spec.rs b/crates/lib/src/spec.rs index 18d675402..e3a8c12b3 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -10,6 +10,7 @@ use ostree_ext::{container::OstreeImageReference, oci_spec}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::install::BootType; use crate::{k8sapitypes, status::Slot}; const API_VERSION: &str = "org.containers.bootc/v1"; @@ -164,6 +165,16 @@ pub struct BootEntryOstree { pub deploy_serial: u32, } +/// A bootable entry +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct BootEntryComposefs { + /// The erofs verity + pub verity: String, + /// Whether this deployment is to be booted via BLS or UKI + pub boot_type: BootType, +} + /// A bootable entry #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -181,6 +192,8 @@ pub struct BootEntry { pub store: Option, /// If this boot entry is ostree based, the corresponding state pub ostree: Option, + /// If this boot entry is composefs based, the corresponding state + pub composefs: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -520,6 +533,7 @@ mod tests { pinned: false, store: None, ostree: None, + composefs: None, } } diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 910e3f567..a760ed616 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -3,19 +3,35 @@ use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; use std::io::Write; +use std::str::FromStr; +use std::sync::OnceLock; use anyhow::{Context, Result}; +use bootc_utils::try_deserialize_timestamp; use canon_json::CanonJsonSerialize; +use cap_std_ext::cap_std; use fn_error_context::context; use ostree::glib; use ostree_container::OstreeImageReference; use ostree_ext::container as ostree_container; +use ostree_ext::container::deploy::ORIGIN_CONTAINER; use ostree_ext::container_utils::ostree_booted; +use ostree_ext::containers_image_proxy; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::oci_spec; +use ostree_ext::oci_spec::image::ImageManifest; use ostree_ext::ostree; +use tokio::io::AsyncReadExt; use crate::cli::OutputFormat; +use crate::composefs_consts::{ + COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE, +}; +use crate::deploy::get_sorted_bls_boot_entries; +use crate::deploy::get_sorted_uki_boot_entries; +use crate::install::BootType; +use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; use crate::spec::{ImageReference, ImageSignature}; use crate::store::{CachedImageStatus, ContainerImageStore, Storage}; @@ -42,6 +58,21 @@ impl From for ostree_container::SignatureSource { } } +/// Detect if we have composefs= in /proc/cmdline +pub(crate) fn composefs_booted() -> Result> { + static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); + if let Some(v) = CACHED_DIGEST_VALUE.get() { + return Ok(v.as_deref()); + } + let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; + let Some(kv) = cmdline.find_str("composefs") else { + return Ok(None); + }; + let Some(v) = kv.value else { return Ok(None) }; + let r = CACHED_DIGEST_VALUE.get_or_init(|| Some(v.to_owned())); + Ok(r.as_deref()) +} + /// Fixme lower serializability into ostree-ext fn transport_to_string(transport: ostree_container::Transport) -> String { match transport { @@ -156,6 +187,7 @@ fn boot_entry_from_deployment( deploy_serial: deployment.deployserial().try_into().unwrap(), stateroot: deployment.stateroot().into(), }), + composefs: None, }; Ok(r) } @@ -284,6 +316,220 @@ pub(crate) fn get_status( Ok((deployments, host)) } +/// imgref = transport:image_name +#[context("Getting container info")] +async fn get_container_manifest_and_config( + imgref: &String, +) -> Result<(ImageManifest, oci_spec::image::ImageConfiguration)> { + let config = containers_image_proxy::ImageProxyConfig::default(); + let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?; + + let img = proxy.open_image(&imgref).await.context("Opening image")?; + + let (_, manifest) = proxy.fetch_manifest(&img).await?; + let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?; + + let mut buf = Vec::with_capacity(manifest.config().size() as usize); + buf.resize(manifest.config().size() as usize, 0); + reader.read_exact(&mut buf).await?; + driver.await?; + + let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?; + + Ok((manifest, config)) +} + +#[context("Getting composefs deployment metadata")] +async fn boot_entry_from_composefs_deployment( + origin: tini::Ini, + verity: String, +) -> Result { + let image = match origin.get::("origin", ORIGIN_CONTAINER) { + Some(img_name_from_config) => { + let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; + let imgref = ostree_img_ref.imgref.to_string(); + let img_ref = ImageReference::from(ostree_img_ref); + + // The image might've been removed, so don't error if we can't get the image manifest + let (image_digest, version, architecture, created_at) = + match get_container_manifest_and_config(&imgref).await { + Ok((manifest, config)) => { + let digest = manifest.config().digest().to_string(); + let arch = config.architecture().to_string(); + let created = config.created().clone(); + let version = manifest + .annotations() + .as_ref() + .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned()); + + (digest, version, arch, created) + } + + Err(e) => { + tracing::debug!("Failed to open image {img_ref}, because {e:?}"); + ("".into(), None, "".into(), None) + } + }; + + let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x)); + + let image_status = ImageStatus { + image: img_ref, + version, + timestamp, + image_digest, + architecture, + }; + + Some(image_status) + } + + // Wasn't booted using a container image. Do nothing + None => None, + }; + + let boot_type = match origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) { + Some(s) => BootType::try_from(s.as_str())?, + None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"), + }; + + let e = BootEntry { + image, + cached_update: None, + incompatible: false, + pinned: false, + store: None, + ostree: None, + composefs: Some(crate::spec::BootEntryComposefs { verity, boot_type }), + }; + + return Ok(e); +} + +#[context("Getting composefs deployment status")] +pub(crate) async fn composefs_deployment_status() -> Result { + let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; + let composefs_arg = cmdline + .find_str(COMPOSEFS_CMDLINE) + .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; + let booted_image_verity = composefs_arg + .value + .ok_or_else(|| anyhow::anyhow!("Missing value for composefs"))?; + + let sysroot = cap_std::fs::Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority()) + .context("Opening sysroot")?; + let deployments = sysroot + .read_dir(STATE_DIR_RELATIVE) + .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?; + + let host_spec = HostSpec { + image: None, + boot_order: BootOrder::Default, + }; + + let mut host = Host::new(host_spec); + + let staged_deployment_id = match std::fs::File::open(format!( + "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" + )) { + Ok(mut f) => { + let mut s = String::new(); + f.read_to_string(&mut s)?; + + Ok(Some(s)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + }?; + + // NOTE: This cannot work if we support both BLS and UKI at the same time + let mut boot_type: Option = None; + + for depl in deployments { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.to_string_lossy(); + + // read the origin file + let config = depl + .open_dir() + .with_context(|| format!("Failed to open {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .with_context(|| format!("Reading file {depl_file_name}.origin"))?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + let boot_entry = + boot_entry_from_composefs_deployment(ini, depl_file_name.to_string()).await?; + + // SAFETY: boot_entry.composefs will always be present + let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; + + match boot_type { + Some(current_type) => { + if current_type != boot_type_from_origin { + anyhow::bail!("Conflicting boot types") + } + } + + None => { + boot_type = Some(boot_type_from_origin); + } + }; + + if depl.file_name() == booted_image_verity { + host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); + host.status.booted = Some(boot_entry); + continue; + } + + if let Some(staged_deployment_id) = &staged_deployment_id { + if depl_file_name == staged_deployment_id.trim() { + host.status.staged = Some(boot_entry); + continue; + } + } + + host.status.rollback = Some(boot_entry); + } + + // Shouldn't really happen, but for sanity nonetheless + let Some(boot_type) = boot_type else { + anyhow::bail!("Could not determine boot type"); + }; + + let boot_dir = sysroot.open_dir("boot").context("Opening boot dir")?; + + match boot_type { + BootType::Bls => { + host.status.rollback_queued = !get_sorted_bls_boot_entries(&boot_dir, false)? + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .options + .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 + }; + + Ok(host) +} + /// Implementation of the `bootc status` CLI command. #[context("Status")] pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { @@ -292,13 +538,15 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { 0 | 1 => {} o => anyhow::bail!("Unsupported format version: {o}"), }; - let mut host = if !ostree_booted()? { - Default::default() - } else { + let mut host = if ostree_booted()? { let sysroot = super::cli::get_storage().await?; let booted_deployment = sysroot.booted_deployment(); let (_deployments, host) = get_status(&sysroot, booted_deployment.as_ref())?; host + } else if composefs_booted()?.is_some() { + composefs_deployment_status().await? + } else { + Default::default() }; // We could support querying the staged or rollback deployments @@ -412,6 +660,12 @@ fn human_render_slot( let digest = &image.image_digest; writeln!(out, "{digest} ({arch})")?; + // Write the EROFS verity if present + if let Some(composefs) = &entry.composefs { + write_row_name(&mut out, "Verity", prefix_len)?; + writeln!(out, "{}", composefs.verity)?; + } + // Format the timestamp without nanoseconds since those are just irrelevant noise for human // consumption - that time scale should basically never matter for container builds. let timestamp = image @@ -506,6 +760,27 @@ fn human_render_slot_ostree( Ok(()) } +/// Output a rendering of a non-container composefs boot entry. +fn human_render_slot_composefs( + mut out: impl Write, + slot: Slot, + entry: &crate::spec::BootEntry, + erofs_verity: &str, +) -> Result<()> { + // TODO consider rendering more ostree stuff here like rpm-ostree status does + let prefix = match slot { + Slot::Staged => " Staged composefs".into(), + Slot::Booted => format!("{} Booted composefs", crate::glyph::Glyph::BlackCircle), + Slot::Rollback => " Rollback composefs".into(), + }; + let prefix_len = prefix.len(); + writeln!(out, "{prefix}")?; + write_row_name(&mut out, "Commit", prefix_len)?; + writeln!(out, "{erofs_verity}")?; + tracing::debug!("pinned={}", entry.pinned); + Ok(()) +} + fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) -> Result<()> { let mut first = true; for (slot_name, status) in [ @@ -529,6 +804,8 @@ fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) &ostree.checksum, verbose, )?; + } else if let Some(composefs) = &host_status.composefs { + human_render_slot_composefs(&mut out, slot_name, host_status, &composefs.verity)?; } else { writeln!(out, "Current {slot_name} state is unknown")?; } 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" + ); + } } diff --git a/crates/ostree-ext/src/lib.rs b/crates/ostree-ext/src/lib.rs index 7889a0220..3123e2450 100644 --- a/crates/ostree-ext/src/lib.rs +++ b/crates/ostree-ext/src/lib.rs @@ -17,6 +17,8 @@ // "Dependencies are re-exported". Users will need e.g. `gio::File`, so this avoids // them needing to update matching versions. pub use composefs; +pub use composefs_boot; +pub use composefs_oci; pub use containers_image_proxy; pub use containers_image_proxy::oci_spec; pub use ostree;