From 30242e00b7ecd7cd726efcf39635f81f180bf822 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 7 May 2025 10:56:43 +0530 Subject: [PATCH 01/20] WIP: composefs backend Signed-off-by: Pragyan Poudyal Signed-off-by: Colin Walters --- crates/lib/src/bls_config.rs | 88 +++ crates/lib/src/bootloader.rs | 11 +- crates/lib/src/cli.rs | 168 ++++- crates/lib/src/deploy.rs | 188 +++++- crates/lib/src/install.rs | 805 ++++++++++++++++++++++- crates/lib/src/install/baseline.rs | 15 +- crates/lib/src/lib.rs | 1 + crates/lib/src/spec.rs | 14 + crates/lib/src/status.rs | 227 ++++++- crates/ostree-ext/src/container_utils.rs | 6 + crates/ostree-ext/src/lib.rs | 2 + 11 files changed, 1457 insertions(+), 68 deletions(-) create mode 100644 crates/lib/src/bls_config.rs diff --git a/crates/lib/src/bls_config.rs b/crates/lib/src/bls_config.rs new file mode 100644 index 000000000..16ffceb78 --- /dev/null +++ b/crates/lib/src/bls_config.rs @@ -0,0 +1,88 @@ +use anyhow::Result; +use serde::de::Error; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; + +#[derive(Debug, Deserialize, Eq)] +pub(crate) struct BLSConfig { + pub(crate) title: Option, + #[serde(deserialize_with = "deserialize_version")] + pub(crate) version: u32, + pub(crate) linux: String, + pub(crate) initrd: String, + pub(crate) options: String, + + #[serde(flatten)] + pub(crate) extra: HashMap, +} + +impl PartialEq for BLSConfig { + fn eq(&self, other: &Self) -> bool { + self.version == other.version + } +} + +impl PartialOrd for BLSConfig { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for BLSConfig { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.version.cmp(&other.version) + } +} + +impl BLSConfig { + pub(crate) fn to_string(&self) -> String { + let mut out = String::new(); + + if let Some(title) = &self.title { + out += &format!("title {}\n", title); + } + + out += &format!("version {}\n", self.version); + out += &format!("linux {}\n", self.linux); + out += &format!("initrd {}\n", self.initrd); + out += &format!("options {}\n", self.options); + + for (key, value) in &self.extra { + out += &format!("{} {}\n", key, value); + } + + out + } +} + +fn deserialize_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + + match s { + Some(s) => Ok(s.parse::().map_err(D::Error::custom)?), + None => Err(D::Error::custom("Version not found")), + } +} + +pub(crate) fn parse_bls_config(input: &str) -> Result { + let mut map = HashMap::new(); + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(' ') { + map.insert(key.to_string(), value.trim().to_string()); + } + } + + let value = serde_json::to_value(map)?; + let parsed: BLSConfig = serde_json::from_value(value)?; + + Ok(parsed) +} diff --git a/crates/lib/src/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 7b19bf7de..981bf5e75 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -21,18 +21,23 @@ use ostree_ext::composefs::fsverity; use ostree_ext::composefs::fsverity::FsVerityHashValue; use ostree_ext::composefs::splitstream::SplitStreamWriter; use ostree_ext::container as ostree_container; -use ostree_ext::container_utils::ostree_booted; +use ostree_ext::container_utils::{composefs_booted, ostree_booted}; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; use ostree_ext::sysroot::SysrootLock; 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_deployment_status; use crate::utils::sigpolicy_from_opt; /// Shared progress options @@ -903,6 +908,53 @@ 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); + + 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), + }?; + + write_composefs_state(&Utf8PathBuf::from("/sysroot"), id, imgref, true, boot_type)?; + + Ok(()) +} + /// Implementation of the `bootc upgrade` CLI command. #[context("Upgrading")] async fn upgrade(opts: UpgradeOpts) -> Result<()> { @@ -1024,9 +1076,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, @@ -1035,6 +1085,63 @@ 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); + + 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), + }?; + + write_composefs_state( + &Utf8PathBuf::from("/sysroot"), + id, + &target_imgref, + true, + boot_type, + )?; + + 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 @@ -1106,21 +1213,25 @@ 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?; - let ostree = sysroot.get_ostree()?; - crate::deploy::rollback(sysroot).await?; + if composefs_booted()? { + composefs_rollback().await? + } else { + let sysroot = &get_storage().await?; + let ostree = sysroot.get_ostree()?; + crate::deploy::rollback(sysroot).await?; - if opts.soft_reboot.is_some() { - // Get status of rollback deployment to check soft-reboot capability - let host = crate::status::get_status_require_booted(ostree)?.2; - - handle_soft_reboot( - opts.soft_reboot, - host.status.rollback.as_ref(), - "rollback", - || soft_reboot_rollback(ostree), - )?; - } + if opts.soft_reboot.is_some() { + // Get status of rollback deployment to check soft-reboot capability + let host = crate::status::get_status_require_booted(ostree)?.2; + + handle_soft_reboot( + opts.soft_reboot, + host.status.rollback.as_ref(), + "rollback", + || soft_reboot_rollback(ostree), + )?; + } + }; if opts.apply { crate::reboot::reboot()?; @@ -1270,8 +1381,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()? { + upgrade_composefs(opts).await + } else { + upgrade(opts).await + } + } + Opt::Switch(opts) => { + if composefs_booted()? { + switch_composefs(opts).await + } else { + switch(opts).await + } + } Opt::Rollback(opts) => rollback(opts).await, Opt::Edit(opts) => edit(opts).await, Opt::UsrOverlay => usroverlay().await, @@ -1412,8 +1535,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/deploy.rs b/crates/lib/src/deploy.rs index e10500ab9..793c30875 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -3,7 +3,9 @@ //! Create a merged filesystem tree with the image and mounted configmaps. use std::collections::HashSet; +use std::fs::create_dir_all; use std::io::{BufRead, Write}; +use std::path::PathBuf; use anyhow::Ok; use anyhow::{anyhow, Context, Result}; @@ -20,11 +22,15 @@ 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::bls_config::{parse_bls_config, BLSConfig}; +#[allow(unused_imports)] +use crate::install::{get_efi_uuid_source, get_user_config, BootType}; 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::spec::{BootEntry, BootOrder, HostSpec}; +use crate::status::{composefs_deployment_status, labels_of_config}; use crate::store::Storage; use crate::utils::async_task_with_spinner; @@ -740,6 +746,184 @@ pub(crate) async fn stage( Ok(()) } +#[context("Rolling back UKI")] +pub(crate) fn rollback_composefs_uki(_current: &BootEntry, _rollback: &BootEntry) -> Result<()> { + unimplemented!() + // let user_cfg_name = "grub2/user.cfg.staged"; + // let user_cfg_path = PathBuf::from("boot").join(user_cfg_name); + // let sysroot = &Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())?; + + // let efi_uuid_source = get_efi_uuid_source(); + + // let rollback_verity = if let Some(composefs) = &rollback.composefs { + // composefs.verity.clone() + // } else { + // // Shouldn't really happen + // anyhow::bail!("Verity not found for rollback deployment") + // }; + // let rollback_config = get_user_config(todo!(), &rollback_verity).as_bytes(); + + // let current_verity = if let Some(composefs) = ¤t.composefs { + // composefs.verity.clone() + // } else { + // // Shouldn't really happen + // anyhow::bail!("Verity not found for booted deployment") + // }; + // let current_config = get_user_config(todo!(), ¤t_verity).as_bytes(); + + // // TODO: Need to check if user.cfg.staged exists + // sysroot + // .atomic_replace_with(user_cfg_path, |w| { + // write!(w, "{efi_uuid_source}")?; + // w.write_all(rollback_config)?; + // w.write_all(current_config)?; + // Ok(()) + // }) + // .with_context(|| format!("Writing {user_cfg_name}"))?; + + // Ok(()) +} + +/// Filename for `loader/entries` +const CURRENT_ENTRIES: &str = "entries"; +const STAGED_ENTRIES: &str = "entries.staged"; +const ROLLBACK_ENTRIES: &str = STAGED_ENTRIES; + +#[context("Getting boot entries")] +pub(crate) fn get_sorted_boot_entries(ascending: bool) -> Result> { + let mut all_configs = vec![]; + + for entry in std::fs::read_dir(format!("/sysroot/boot/loader/{CURRENT_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 contents = std::fs::read_to_string(&entry.path()) + .with_context(|| format!("Failed to read {:?}", entry.path()))?; + + 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<()> { + // 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)?; + + // Update the indicies so that they're swapped + for (idx, cfg) in all_configs.iter_mut().enumerate() { + cfg.version = idx as u32; + } + + assert!(all_configs.len() == 2); + + // Write these + let dir_path = PathBuf::from(format!("/sysroot/boot/loader/{ROLLBACK_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().as_bytes()) + .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_ENTRIES} and {CURRENT_ENTRIES}"); + renameat_with( + &dir, + ROLLBACK_ENTRIES, + &dir, + CURRENT_ENTRIES, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {ROLLBACK_ENTRIES}"); + rustix::fs::unlinkat(&dir, ROLLBACK_ENTRIES, AtFlags::REMOVEDIR).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(&host.status.booted.unwrap(), &rollback_status), + }?; + + Ok(()) +} + /// Implementation of rollback functionality pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> { const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 296b2ba4e..2f787bdd4 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -15,10 +15,12 @@ mod osbuild; pub(crate) mod osconfig; use std::collections::HashMap; -use std::io::Write; +use std::fs::create_dir_all; +use std::io::{Read, 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; @@ -26,6 +28,7 @@ use std::time::Duration; use aleph::InstallAleph; use anyhow::{anyhow, ensure, Context, Result}; +use bootc_blockdev::{find_parent_devices, PartitionTable}; use bootc_utils::CommandRunExt; use camino::Utf8Path; use camino::Utf8PathBuf; @@ -40,18 +43,37 @@ use cap_std_ext::prelude::CapStdExtDirExt; use clap::ValueEnum; use fn_error_context::context; use ostree::gio; +use ostree_ext::composefs::tree::RegularFile; +use ostree_ext::composefs::{ + fsverity::{FsVerityHashValue, Sha256HashValue}, + repository::Repository as ComposefsRepository, + util::Sha256Digest, +}; +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::bls_config::{parse_bls_config, BLSConfig}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; use crate::containerenv::ContainerExecutionInfo; use crate::deploy::{prepare_for_pull, pull_from_prepared, PreparedImportMeta, PreparedPullResult}; @@ -62,7 +84,7 @@ use crate::spec::ImageReference; use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; -use bootc_mount::Filesystem; +use bootc_mount::{inspect_filesystem, Filesystem}; /// The toplevel boot directory const BOOT: &str = "boot"; @@ -82,6 +104,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 +212,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 +244,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 +319,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 +449,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 +467,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 { @@ -508,6 +596,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 SourceInfo { // Inspect container information and convert it to an ostree image reference // that pulls from containers-storage. @@ -1130,6 +1232,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()) @@ -1274,6 +1377,7 @@ async fn prepare_install( container_root: rootfs, tempdir, host_is_container, + composefs_options: composefs_opts, }); Ok(state) @@ -1310,7 +1414,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"); @@ -1372,6 +1476,618 @@ impl BoundImages { } } +pub(crate) fn open_composefs_repo( + rootfs_dir: &Dir, +) -> Result> { + ComposefsRepository::open_path(rootfs_dir, "composefs") + .context("Failed to open composefs repository") +} + +async fn initialize_composefs_repository( + state: &State, + root_setup: &RootSetup, +) -> Result<(Sha256Digest, impl FsVerityHashValue)> { + let rootfs_dir = &root_setup.physical_root; + + rootfs_dir + .create_dir_all("composefs") + .context("Creating dir 'composefs'")?; + + let repo = open_composefs_repo(rootfs_dir)?; + + let OstreeExtImgRef { + name: image_name, + transport, + } = &state.source.imageref; + + // transport's display is already of type ":" + composefs_oci_pull( + &Arc::new(repo), + &format!("{transport}{image_name}"), + None, + None, + ) + .await +} + +fn get_booted_bls() -> Result { + let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; + let booted = cmdline + .find_str("composefs") + .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 fn read_file( + file: &RegularFile, + repo: &ComposefsRepository, +) -> Result> { + match file { + RegularFile::Inline(data) => Ok(data.clone()), + RegularFile::External(id, size) => { + let mut data = vec![]; + std::fs::File::from(repo.open_object(id)?).read_to_end(&mut data)?; + ensure!( + *size == data.len() as u64, + "File content doesn't have the expected length" + ); + Ok(data.into_boxed_slice()) + } + } +} + +pub(crate) enum BootSetupType<'a> { + /// For initial setup, i.e. install to-disk + Setup((&'a RootSetup, &'a State)), + /// For `bootc upgrade` + Upgrade, +} + +#[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=?{id_hex}")); + } + None | Some(..) => { + cmdline_options.push_str(&format!(" composefs={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={id_hex}"), + ] + .join(" "), + ), + }; + + let boot_dir = root_path.join("boot"); + + let bls_config = match &entry { + ComposefsBootEntry::Type1(..) => unimplemented!(), + ComposefsBootEntry::Type2(..) => unimplemented!(), + ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), + + ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { + // Write the initrd and vmlinuz at /boot// + let path = boot_dir.join(&id_hex); + create_dir_all(&path)?; + + let entries_dir = + cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + + entries_dir + .atomic_write( + "vmlinuz", + read_file(&usr_lib_modules_vmlinuz.vmlinuz, &repo) + .context("Reading vmlinuz")?, + ) + .context("Writing vmlinuz to path")?; + + if let Some(initramfs) = &usr_lib_modules_vmlinuz.initramfs { + entries_dir + .atomic_write( + "initrd", + read_file(initramfs, &repo).context("Reading initrd")?, + ) + .context("Writing initrd to path")?; + } else { + anyhow::bail!("initramfs not found"); + }; + + // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd + let owned_fd = entries_dir + .reopen_as_ownedfd() + .context("Reopen as owned fd")?; + + rustix::fs::fsync(owned_fd).context("fsync")?; + + 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(), + } + } + }; + + let (entries_path, booted_bls) = if matches!(setup_type, BootSetupType::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)) + } else { + (boot_dir.join("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(()) +} + +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)) +} + +pub(crate) fn get_user_config(boot_label: &String, uki_id: &str) -> String { + // TODO: Full EFI path here + let s = format!( + r#" +menuentry "{boot_label}: ({uki_id})" {{ + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${{EFI_PART_UUID}}" + chainloader /EFI/Linux/{uki_id}.efi +}} +"# + ); + + return s; +} + +/// Contains the EFP's filesystem UUID. Used by grub +pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; + +/// 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(get_user_config(&boot_label, &id.to_hex()).as_bytes())?; + + // root_path here will be /sysroot + for entry in std::fs::read_dir(root_path.join(STATE_DIR_RELATIVE))? { + let entry = entry?; + + let depl_file_name = entry.file_name(); + // SAFETY: Deployment file name shouldn't containg non UTF-8 chars + let depl_file_name = depl_file_name.to_string_lossy(); + + buffer.write_all(get_user_config(&boot_label, &depl_file_name).as_bytes())?; + } + + 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(get_user_config(&boot_label, &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(()) +} + +/// 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, 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); + + match boot_type { + BootType::Bls => setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state)), + repo, + &id, + entry, + )?, + 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, + )?; + + 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( + root_path: &Utf8PathBuf, + deployment_id: Sha256HashValue, + imgref: &ImageReference, + staged: bool, + boot_type: BootType, +) -> 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")); + create_dir_all(&actual_var_path)?; + + symlink(Path::new("../../os/fedora/var"), 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); + + let mut origin_file = + std::fs::File::create(state_path.join(format!("{}.origin", deployment_id.to_hex()))) + .context("Failed to open .origin file")?; + + origin_file + .write(config.to_string().as_bytes()) + .context("Falied to write to .origin file")?; + + if staged { + std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) + .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + + let buf = deployment_id.to_hex(); + std::fs::write(COMPOSEFS_STAGED_DEPLOYMENT_PATH, buf) + .with_context(|| format!("Writing {COMPOSEFS_STAGED_DEPLOYMENT_PATH}"))?; + } + + Ok(()) +} + async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, @@ -1404,34 +2120,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) = initialize_ostree_root(state, rootfs).await?; - - install_with_sysroot( - state, - rootfs, - &sysroot, - &boot_uuid, - bound_images, - has_ostree, - ) - .await?; - let ostree = sysroot.get_ostree()?; + 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(ostree)?; - 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) = initialize_ostree_root(state, rootfs).await?; + + install_with_sysroot( + state, + rootfs, + &sysroot, + &boot_uuid, + bound_images, + has_ostree, + ) + .await?; + let ostree = sysroot.get_ostree()?; - // 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(ostree)?; + 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 { @@ -1452,6 +2181,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 @@ -1473,7 +2204,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) = { @@ -1682,7 +2423,7 @@ pub(crate) async fn install_to_filesystem( // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT. // IMPORTANT: In practice, we should only be gathering information before this point, // IMPORTANT: and not performing any mutations at all. - let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts).await?; + let state = prepare_install(opts.config_opts, opts.source_opts, opts.target_opts, None).await?; // And the last bit of state here is the fsopts, which we also destructure now. let mut fsopts = opts.filesystem_opts; diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 1cd48c48f..e5f30ca1c 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -42,8 +42,6 @@ pub(crate) const LINUX_PARTTYPE: &str = "0FC63DAF-8483-4772-8E79-3D69D8477DE4"; pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; #[cfg(feature = "install-to-disk")] pub(crate) const PREPBOOT_LABEL: &str = "PowerPC-PReP-boot"; -#[cfg(feature = "install-to-disk")] -pub(crate) const ESP_GUID: &str = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B"; #[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -106,10 +104,15 @@ fn mkfs<'a>( label: &str, wipe: bool, opts: impl IntoIterator, + dps_uuid: Option, ) -> Result { let devinfo = bootc_blockdev::list_dev(dev.into())?; let size = ostree_ext::glib::format_size(devinfo.size); - let u = uuid::Uuid::new_v4(); + let u = if let Some(u) = dps_uuid { + u + } else { + uuid::Uuid::new_v4() + }; let mut t = Task::new( &format!("Creating {label} filesystem ({fs}) on device {dev} (size={size})"), format!("mkfs.{fs}"), @@ -275,7 +278,7 @@ pub(crate) fn install_create_rootfs( } let esp_partno = if super::ARCH_USES_EFI { - let esp_guid = ESP_GUID; + let esp_guid = crate::install::ESP_GUID; partno += 1; writeln!( &mut partitioning_buf, @@ -383,6 +386,7 @@ pub(crate) fn install_create_rootfs( "boot", opts.wipe, [], + None, ) .context("Initializing /boot")?, ) @@ -403,6 +407,8 @@ pub(crate) fn install_create_rootfs( "root", opts.wipe, mkfs_options.iter().copied(), + // TODO: Add cli option for this + Some(uuid::uuid!(crate::install::DPS_UUID)), )?; let rootarg = format!("root=UUID={root_uuid}"); let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); @@ -418,6 +424,7 @@ pub(crate) fn install_create_rootfs( .flatten() .chain([rootarg, RW_KARG.to_string()].into_iter()) .chain(bootarg) + .chain(state.config_opts.karg.clone().into_iter().flatten()) .collect::>(); bootc_mount::mount(&rootdev, &physical_root_path)?; diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 309d027a0..31b51003e 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -4,6 +4,7 @@ //! to provide a fully "container native" tool for using //! bootable container images. +mod bls_config; pub(crate) mod bootc_kargs; mod boundimage; mod cfsctl; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 2d8593fde..8f0ac7b86 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")] @@ -184,6 +195,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)] @@ -524,6 +537,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 d9b33e431..b87a36c6d 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -3,22 +3,36 @@ use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; use std::io::Write; +use std::str::FromStr; 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::composefs_booted; use ostree_ext::container_utils::ostree_booted; +use ostree_ext::containers_image_proxy; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::oci_spec; use ostree_ext::oci_spec::image::Digest; use ostree_ext::oci_spec::image::ImageConfiguration; -use ostree_ext::ostree; use ostree_ext::sysroot::SysrootLock; +use ostree_ext::oci_spec::image::ImageManifest; +use ostree_ext::ostree; +use tokio::io::AsyncReadExt; + use crate::cli::OutputFormat; +use crate::deploy::get_sorted_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}; @@ -207,6 +221,7 @@ fn boot_entry_from_deployment( deploy_serial: deployment.deployserial().try_into().unwrap(), stateroot: deployment.stateroot().into(), }), + composefs: None, }; Ok(r) } @@ -335,6 +350,179 @@ 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 }), + soft_reboot_capable: false + }; + + 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") + .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(COMPOSEFS_STAGED_DEPLOYMENT_PATH) { + 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), + }?; + + 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?; + + 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); + } + + host.status.rollback_queued = !get_sorted_boot_entries(false)? + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .options + .contains(composefs_arg.as_ref()); + + 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<()> { @@ -343,14 +531,16 @@ 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 ostree = sysroot.get_ostree()?; let booted_deployment = ostree.booted_deployment(); let (_deployments, host) = get_status(&ostree, booted_deployment.as_ref())?; host + } else if composefs_booted()? { + composefs_deployment_status().await? + } else { + Default::default() }; // We could support querying the staged or rollback deployments @@ -485,6 +675,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 @@ -585,6 +781,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 [ @@ -608,6 +825,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/ostree-ext/src/container_utils.rs b/crates/ostree-ext/src/container_utils.rs index 7737e986c..9cfaef7a0 100644 --- a/crates/ostree-ext/src/container_utils.rs +++ b/crates/ostree-ext/src/container_utils.rs @@ -77,6 +77,12 @@ 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) diff --git a/crates/ostree-ext/src/lib.rs b/crates/ostree-ext/src/lib.rs index 40cd2d084..0c53ec618 100644 --- a/crates/ostree-ext/src/lib.rs +++ b/crates/ostree-ext/src/lib.rs @@ -17,6 +17,8 @@ // "Dependencies are re-exported". Users will need e.g. `gio::File`, so this avoids // them needing to update matching versions. pub use composefs; +pub use composefs_boot; +pub use composefs_oci; pub use containers_image_proxy; pub use containers_image_proxy::oci_spec; pub use ostree; From 1e6bb6a574fa69ca29e39bb8210056d451b371b2 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 7 Aug 2025 11:03:01 -0400 Subject: [PATCH 02/20] Rework composefs_booted to use kernel cmdline Signed-off-by: Colin Walters --- crates/lib/src/cli.rs | 10 +++++----- crates/lib/src/status.rs | 19 +++++++++++++++++-- crates/ostree-ext/src/container_utils.rs | 6 ------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 981bf5e75..7ed1b33eb 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -21,7 +21,7 @@ use ostree_ext::composefs::fsverity; use ostree_ext::composefs::fsverity::FsVerityHashValue; use ostree_ext::composefs::splitstream::SplitStreamWriter; 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 ostree_ext::sysroot::SysrootLock; @@ -37,7 +37,7 @@ use crate::lints; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; use crate::spec::ImageReference; -use crate::status::composefs_deployment_status; +use crate::status::{composefs_booted, composefs_deployment_status}; use crate::utils::sigpolicy_from_opt; /// Shared progress options @@ -1213,7 +1213,7 @@ async fn switch(opts: SwitchOpts) -> Result<()> { /// Implementation of the `bootc rollback` CLI command. #[context("Rollback")] async fn rollback(opts: RollbackOpts) -> Result<()> { - if composefs_booted()? { + if composefs_booted()?.is_some() { composefs_rollback().await? } else { let sysroot = &get_storage().await?; @@ -1382,14 +1382,14 @@ async fn run_from_opt(opt: Opt) -> Result<()> { let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; match opt { Opt::Upgrade(opts) => { - if composefs_booted()? { + if composefs_booted()?.is_some() { upgrade_composefs(opts).await } else { upgrade(opts).await } } Opt::Switch(opts) => { - if composefs_booted()? { + if composefs_booted()?.is_some() { switch_composefs(opts).await } else { switch(opts).await diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index b87a36c6d..f27df754f 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -4,6 +4,7 @@ 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; @@ -14,7 +15,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; @@ -60,6 +60,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 { @@ -537,7 +552,7 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { let booted_deployment = ostree.booted_deployment(); let (_deployments, host) = get_status(&ostree, booted_deployment.as_ref())?; host - } else if composefs_booted()? { + } else if composefs_booted()?.is_some() { composefs_deployment_status().await? } else { Default::default() diff --git a/crates/ostree-ext/src/container_utils.rs b/crates/ostree-ext/src/container_utils.rs index 9cfaef7a0..7737e986c 100644 --- a/crates/ostree-ext/src/container_utils.rs +++ b/crates/ostree-ext/src/container_utils.rs @@ -77,12 +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) From c16c2c82c9d774970faa8e9b434e34360ab02f9f Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Sun, 27 Jul 2025 11:04:36 +0530 Subject: [PATCH 03/20] composefs/status: Read UKI entries to check for queued rollback Parse the Grub menuentry file, `boot/grub2/user.cfg` to get a list of bootable UKIs and figure out if a rollback is currently queued. Signed-off-by: Johan-Liebert1 --- crates/lib/src/deploy.rs | 14 ++++-- crates/lib/src/kernel_cmdline.rs | 2 +- crates/lib/src/parsers/grub_menuconfig.rs | 8 ++-- crates/lib/src/status.rs | 52 ++++++++++++++++++++--- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 793c30875..9a6f824db 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::fs::create_dir_all; -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read as _, Write}; use std::path::PathBuf; use anyhow::Ok; @@ -27,6 +27,7 @@ use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; use crate::bls_config::{parse_bls_config, BLSConfig}; #[allow(unused_imports)] use crate::install::{get_efi_uuid_source, get_user_config, BootType}; +use crate::parsers::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; use crate::spec::ImageReference; use crate::spec::{BootEntry, BootOrder, HostSpec}; @@ -789,8 +790,15 @@ const CURRENT_ENTRIES: &str = "entries"; const STAGED_ENTRIES: &str = "entries.staged"; const ROLLBACK_ENTRIES: &str = STAGED_ENTRIES; +// Need str to store lifetime +pub(crate) fn get_sorted_uki_boot_entries<'a>(str: &'a mut String) -> Result>> { + let mut file = std::fs::File::open("/sysroot/boot/grub2/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> { +pub(crate) fn get_sorted_bls_boot_entries(ascending: bool) -> Result> { let mut all_configs = vec![]; for entry in std::fs::read_dir(format!("/sysroot/boot/loader/{CURRENT_ENTRIES}"))? { @@ -825,7 +833,7 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { // 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(false)?; // Update the indicies so that they're swapped for (idx, cfg) in all_configs.iter_mut().enumerate() { 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/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 146903ef9..89d0c7647 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -14,13 +14,13 @@ 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: Option<&'a str>, /// Search command (optional) - search: Option<&'a str>, + pub(crate) search: Option<&'a str>, /// Additional commands - extra: Vec<(&'a str, &'a str)>, + pub(crate) extra: Vec<(&'a str, &'a str)>, } impl<'a> Display for MenuentryBody<'a> { diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index f27df754f..d4ef98165 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -28,7 +28,8 @@ use ostree_ext::ostree; use tokio::io::AsyncReadExt; use crate::cli::OutputFormat; -use crate::deploy::get_sorted_boot_entries; +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; @@ -490,6 +491,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?; @@ -509,6 +513,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); @@ -525,11 +544,32 @@ pub(crate) async fn composefs_deployment_status() -> Result { host.status.rollback = Some(boot_entry); } - host.status.rollback_queued = !get_sorted_boot_entries(false)? - .first() - .ok_or(anyhow::anyhow!("First boot entry not found"))? - .options - .contains(composefs_arg.as_ref()); + // Shouldn't really happen, but for sanity nonetheless + let Some(boot_type) = boot_type else { + anyhow::bail!("Could not determine boot type"); + }; + + match boot_type { + BootType::Bls => { + host.status.rollback_queued = !get_sorted_bls_boot_entries(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(&mut s)? + .first() + .ok_or(anyhow::anyhow!("First boot entry not found"))? + .body + .chainloader + .map(|v| v.contains(composefs_arg.as_ref())) + .unwrap_or_default() + } + }; if host.status.rollback_queued { host.spec.boot_order = BootOrder::Rollback From 4f1d99e40761e83ea2bd00ec0f8dcc16cea6113e Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Sun, 27 Jul 2025 12:51:02 +0530 Subject: [PATCH 04/20] parser/grub: Use String instead of &str Returning a local reference to a `&str` is quite tricky with rust. Update `title` and `chainloader`, the two dynamic fields in the grub menuentry, to be `String` instead of `&str` Signed-off-by: Johan-Liebert1 --- crates/lib/src/parsers/grub_menuconfig.rs | 82 +++++++++++++---------- crates/lib/src/status.rs | 3 +- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 89d0c7647..b5e8f4e75 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -16,9 +16,11 @@ pub(crate) struct MenuentryBody<'a> { /// Kernel modules to load pub(crate) insmod: Vec<&'a str>, /// Chainloader path (optional) - pub(crate) chainloader: Option<&'a str>, + pub(crate) chainloader: String, /// Search command (optional) - pub(crate) search: Option<&'a str>, + pub(crate) search: &'a str, + /// The version + pub(crate) version: u8, /// Additional commands pub(crate) extra: Vec<(&'a str, &'a str)>, } @@ -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)), } @@ -73,7 +70,7 @@ impl<'a> From> for MenuentryBody<'a> { #[derive(Debug, PartialEq, Eq)] pub(crate) struct MenuEntry<'a> { /// Display title (supports escaped quotes) - pub(crate) title: &'a str, + pub(crate) title: String, /// Commands within the menuentry block pub(crate) body: MenuentryBody<'a>, } @@ -86,6 +83,22 @@ impl<'a> Display for MenuEntry<'a> { } } +impl<'a> MenuEntry<'a> { + #[allow(dead_code)] + pub(crate) fn new(boot_label: &str, uki_id: &str) -> Self { + Self { + title: format!("{boot_label}: ({uki_id})"), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: format!("/EFI/Linux/{uki_id}.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + } + } +} + /// Parser that takes content until balanced brackets, handling nested brackets and escapes. fn take_until_balanced_allow_nested( opening_bracket: char, @@ -180,7 +193,7 @@ fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> { Ok(( input, MenuEntry { - title, + title: title.to_string(), body: MenuentryBody::from(map), }, )) @@ -272,20 +285,22 @@ mod test { let expected = vec![ MenuEntry { - title: "Fedora 42: (Verity-42)", + title: "Fedora 42: (Verity-42)".into(), body: MenuentryBody { insmod: vec!["fat", "chain"], - search: Some("--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\""), - chainloader: Some("/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + version: 0, extra: vec![], }, }, MenuEntry { - title: "Fedora 43: (Verity-43)", + title: "Fedora 43: (Verity-43)".into(), body: MenuentryBody { insmod: vec!["fat", "chain"], - search: Some("--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\""), - chainloader: Some("/EFI/Linux/uki.efi"), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + chainloader: "/EFI/Linux/uki.efi".into(), + version: 0, extra: vec![ ("extra_field1", "this is extra"), ("extra_field2", "this is also extra") @@ -312,7 +327,7 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].title, "Title with \\\"escaped quotes\\\" inside"); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/test.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); } #[test] @@ -361,8 +376,8 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].title, "Minimal Entry"); assert_eq!(result[0].body.insmod.len(), 0); - assert_eq!(result[0].body.chainloader, None); - assert_eq!(result[0].body.search, None); + assert_eq!(result[0].body.chainloader, ""); + assert_eq!(result[0].body.search, ""); assert_eq!(result[0].body.extra.len(), 0); } @@ -380,8 +395,8 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].body.insmod, vec!["fat", "chain", "ext2"]); - assert_eq!(result[0].body.chainloader, None); - assert_eq!(result[0].body.search, None); + assert_eq!(result[0].body.chainloader, ""); + assert_eq!(result[0].body.search, ""); } #[test] @@ -399,7 +414,7 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].body.insmod, vec!["fat"]); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/test.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); // set commands should be ignored assert!(!result[0].body.extra.iter().any(|(k, _)| k == &"set")); } @@ -421,7 +436,7 @@ mod test { assert_eq!(result.len(), 1); assert_eq!(result[0].title, "Nested Braces"); assert_eq!(result[0].body.insmod, vec!["fat"]); - assert_eq!(result[0].body.chainloader, Some("/EFI/Linux/test.efi")); + assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi"); // The if/fi block should be captured as extra commands assert!(result[0].body.extra.iter().any(|(k, _)| k == &"if")); } @@ -500,12 +515,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/status.rs b/crates/lib/src/status.rs index d4ef98165..1bd210f2b 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -566,8 +566,7 @@ pub(crate) async fn composefs_deployment_status() -> Result { .ok_or(anyhow::anyhow!("First boot entry not found"))? .body .chainloader - .map(|v| v.contains(composefs_arg.as_ref())) - .unwrap_or_default() + .contains(composefs_arg.as_ref()) } }; From 9c53edd8ea467f985109c1757d816b9f4d5faba1 Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Sun, 27 Jul 2025 14:28:49 +0530 Subject: [PATCH 05/20] composefs/rollback: Handle UKI rollback We parse the grub menuentries, get the rollback deployment then perform the rollback, which basically consists of writing a new .staged menuentry file then atomically swapping the staged and the current menuentry. Rollback while there is a staged deployment is still to be handled. Signed-off-by: Johan-Liebert1 --- crates/lib/src/deploy.rs | 104 +++++++++++++++++++++++--------------- crates/lib/src/install.rs | 50 ++++++++---------- 2 files changed, 83 insertions(+), 71 deletions(-) diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 9a6f824db..b6cff69c3 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, Read as _, Write}; +use std::io::{BufRead, Read, Write}; use std::path::PathBuf; use anyhow::Ok; @@ -25,12 +26,11 @@ use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; use crate::bls_config::{parse_bls_config, BLSConfig}; -#[allow(unused_imports)] -use crate::install::{get_efi_uuid_source, get_user_config, BootType}; +use crate::install::{get_efi_uuid_source, BootType}; use crate::parsers::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; use crate::spec::ImageReference; -use crate::spec::{BootEntry, BootOrder, HostSpec}; +use crate::spec::{BootOrder, HostSpec}; use crate::status::{composefs_deployment_status, labels_of_config}; use crate::store::Storage; use crate::utils::async_task_with_spinner; @@ -747,42 +747,61 @@ pub(crate) async fn stage( Ok(()) } +/// Filename for `loader/entries` +pub(crate) const USER_CFG: &str = "user.cfg"; +pub(crate) const USER_CFG_STAGED: &str = "user.cfg.staged"; +pub(crate) const USER_CFG_ROLLBACK: &str = USER_CFG_STAGED; + #[context("Rolling back UKI")] -pub(crate) fn rollback_composefs_uki(_current: &BootEntry, _rollback: &BootEntry) -> Result<()> { - unimplemented!() - // let user_cfg_name = "grub2/user.cfg.staged"; - // let user_cfg_path = PathBuf::from("boot").join(user_cfg_name); - // let sysroot = &Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())?; - - // let efi_uuid_source = get_efi_uuid_source(); - - // let rollback_verity = if let Some(composefs) = &rollback.composefs { - // composefs.verity.clone() - // } else { - // // Shouldn't really happen - // anyhow::bail!("Verity not found for rollback deployment") - // }; - // let rollback_config = get_user_config(todo!(), &rollback_verity).as_bytes(); - - // let current_verity = if let Some(composefs) = ¤t.composefs { - // composefs.verity.clone() - // } else { - // // Shouldn't really happen - // anyhow::bail!("Verity not found for booted deployment") - // }; - // let current_config = get_user_config(todo!(), ¤t_verity).as_bytes(); - - // // TODO: Need to check if user.cfg.staged exists - // sysroot - // .atomic_replace_with(user_cfg_path, |w| { - // write!(w, "{efi_uuid_source}")?; - // w.write_all(rollback_config)?; - // w.write_all(current_config)?; - // Ok(()) - // }) - // .with_context(|| format!("Writing {user_cfg_name}"))?; - - // Ok(()) +pub(crate) fn rollback_composefs_uki() -> Result<()> { + let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); + + let mut str = String::new(); + let mut menuentries = + get_sorted_uki_boot_entries(&mut str).context("Getting UKI boot entries")?; + + // TODO(Johan-Liebert): Currently assuming there are only two deployments + assert!(menuentries.len() == 2); + + let (first, second) = menuentries.split_at_mut(1); + std::mem::swap(&mut first[0], &mut second[0]); + + let mut buffer = get_efi_uuid_source(); + + for entry in menuentries { + write!(buffer, "{entry}")?; + } + + let entries_dir = + cap_std::fs::Dir::open_ambient_dir(&user_cfg_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {user_cfg_path:?}"))?; + + entries_dir + .atomic_write(USER_CFG_ROLLBACK, buffer) + .with_context(|| format!("Writing to {USER_CFG_ROLLBACK}"))?; + + tracing::debug!("Atomically exchanging for {USER_CFG_ROLLBACK} and {USER_CFG}"); + renameat_with( + &entries_dir, + USER_CFG_ROLLBACK, + &entries_dir, + USER_CFG, + RenameFlags::EXCHANGE, + ) + .context("renameat")?; + + tracing::debug!("Removing {USER_CFG_ROLLBACK}"); + rustix::fs::unlinkat(&entries_dir, USER_CFG_ROLLBACK, AtFlags::empty()).context("unlinkat")?; + + tracing::debug!("Syncing to disk"); + fsync( + entries_dir + .reopen_as_ownedfd() + .with_context(|| format!("Reopening {user_cfg_path:?} as owned fd"))?, + ) + .with_context(|| format!("fsync {user_cfg_path:?}"))?; + + Ok(()) } /// Filename for `loader/entries` @@ -840,6 +859,7 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { cfg.version = idx as u32; } + // TODO(Johan-Liebert): Currently assuming there are only two deployments assert!(all_configs.len() == 2); // Write these @@ -855,7 +875,7 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { let file_name = format!("bootc-composefs-{}.conf", cfg.version); rollback_entries_dir - .atomic_write(&file_name, cfg.to_string().as_bytes()) + .atomic_write(&file_name, cfg.to_string()) .with_context(|| format!("Writing to {file_name}"))?; } @@ -882,7 +902,7 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { .context("renameat")?; tracing::debug!("Removing {ROLLBACK_ENTRIES}"); - rustix::fs::unlinkat(&dir, ROLLBACK_ENTRIES, AtFlags::REMOVEDIR).context("unlinkat")?; + rustix::fs::unlinkat(&dir, ROLLBACK_ENTRIES, AtFlags::empty()).context("unlinkat")?; tracing::debug!("Syncing to disk"); fsync( @@ -926,7 +946,7 @@ 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(), }?; Ok(()) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 2f787bdd4..ceb03f138 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -76,9 +76,13 @@ use self::baseline::InstallBlockDeviceOpts; use crate::bls_config::{parse_bls_config, BLSConfig}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; 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, USER_CFG, USER_CFG_STAGED, +}; use crate::kernel_cmdline::Cmdline; use crate::lsm; +use crate::parsers::grub_menuconfig::MenuEntry; use crate::progress_jsonl::ProgressWriter; use crate::spec::ImageReference; use crate::store::Storage; @@ -1697,22 +1701,6 @@ pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { Ok((esp.node, esp.uuid)) } -pub(crate) fn get_user_config(boot_label: &String, uki_id: &str) -> String { - // TODO: Full EFI path here - let s = format!( - r#" -menuentry "{boot_label}: ({uki_id})" {{ - insmod fat - insmod chain - search --no-floppy --set=root --fs-uuid "${{EFI_PART_UUID}}" - chainloader /EFI/Linux/{uki_id}.efi -}} -"# - ); - - return s; -} - /// Contains the EFP's filesystem UUID. Used by grub pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; @@ -1861,9 +1849,9 @@ pub(crate) fn setup_composefs_uki_boot( let efi_uuid_source = get_efi_uuid_source(); let user_cfg_name = if is_upgrade { - "user.cfg.staged" + USER_CFG_STAGED } else { - "user.cfg" + USER_CFG }; let grub_dir = @@ -1878,17 +1866,17 @@ pub(crate) fn setup_composefs_uki_boot( // Shouldn't really fail so no context here buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all(get_user_config(&boot_label, &id.to_hex()).as_bytes())?; - - // root_path here will be /sysroot - for entry in std::fs::read_dir(root_path.join(STATE_DIR_RELATIVE))? { - let entry = entry?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .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(); + let mut str_buf = String::new(); + let entries = get_sorted_uki_boot_entries(&mut str_buf)?; - buffer.write_all(get_user_config(&boot_label, &depl_file_name).as_bytes())?; + for entry in entries { + buffer.write_all(entry.to_string().as_bytes())?; } grub_dir @@ -1916,7 +1904,11 @@ pub(crate) fn setup_composefs_uki_boot( // Shouldn't really fail so no context here buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all(get_user_config(&boot_label, &id.to_hex()).as_bytes())?; + buffer.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; grub_dir .atomic_write(user_cfg_name, buffer) From 442c16def4b9f1e7819ba18a2ebfb3e78848d2c7 Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Mon, 28 Jul 2025 10:44:50 +0530 Subject: [PATCH 06/20] composefs/state: Use atomic writes for origin and staged deployment files Signed-off-by: Johan-Liebert1 --- crates/lib/src/install.rs | 30 +++++++++++++++++++++--------- crates/lib/src/status.rs | 8 ++++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index ceb03f138..df8558c02 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2018,7 +2018,8 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - } pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; -pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_PATH: &str = "/run/composefs/staged-deployment"; +/// File created in /run/composefs to record a staged-deployment +pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_FNAME: &str = "staged-deployment"; /// Relative to /sysroot pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; @@ -2060,21 +2061,32 @@ 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")?; + let state_dir = cap_std::fs::Dir::open_ambient_dir(&state_path, cap_std::ambient_authority()) + .context("Opening state dir")?; - origin_file - .write(config.to_string().as_bytes()) + state_dir + .atomic_write( + format!("{}.origin", deployment_id.to_hex()), + config.to_string().as_bytes(), + ) .context("Falied to write to .origin file")?; if staged { std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; - let buf = deployment_id.to_hex(); - std::fs::write(COMPOSEFS_STAGED_DEPLOYMENT_PATH, buf) - .with_context(|| format!("Writing {COMPOSEFS_STAGED_DEPLOYMENT_PATH}"))?; + let staged_depl_dir = cap_std::fs::Dir::open_ambient_dir( + COMPOSEFS_TRANSIENT_STATE_DIR, + cap_std::ambient_authority(), + ) + .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + + staged_depl_dir + .atomic_write( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + deployment_id.to_hex().as_bytes(), + ) + .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; } Ok(()) diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 1bd210f2b..d650172c8 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -33,7 +33,9 @@ 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::install::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, +}; use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; use crate::spec::{ImageReference, ImageSignature}; @@ -480,7 +482,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)?; From c2d76e32c614e4a4667b6b7a17150e0cc93d95dd Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Mon, 28 Jul 2025 15:04:07 +0530 Subject: [PATCH 07/20] composefs/boot/bls: Handle duplicate VMLinuz + Initrd If two deployments have the same VMLinuz + Initrd then, we can use the same binaries for both the deployments. Before writing the BLS entries to disk we calculate the SHA256Sum of VMLinuz + Initrd combo, then test if any other deployment has the same SHA256Sum for the binaries. Store the hash in the origin file under `boot -> hash` for future lookups. Signed-off-by: Johan-Liebert1 --- crates/lib/src/cli.rs | 39 +++++-- crates/lib/src/install.rs | 215 ++++++++++++++++++++++++++++++-------- 2 files changed, 202 insertions(+), 52 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 7ed1b33eb..0b6ac7ce9 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -944,13 +944,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(()) } @@ -1120,11 +1136,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"), @@ -1132,6 +1156,7 @@ async fn switch_composefs(opts: SwitchOpts) -> Result<()> { &target_imgref, true, boot_type, + boot_digest, )?; Ok(()) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index df8558c02..44b3b2553 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -49,6 +49,7 @@ use ostree_ext::composefs::{ repository::Repository as ComposefsRepository, util::Sha256Digest, }; +use ostree_ext::composefs_boot::bootloader::UsrLibModulesVmlinuz; use ostree_ext::composefs_boot::{ bootloader::BootEntry as ComposefsBootEntry, cmdline::get_cmdline_composefs, uki, BootOps, }; @@ -1563,6 +1564,133 @@ pub(crate) enum BootSetupType<'a> { Upgrade, } +/// Compute SHA256Sum of VMlinuz + Initrd +/// +/// # Arguments +/// * entry - BootEntry containing VMlinuz and Initrd +/// * repo - The composefs repository +#[context("Computing boot digest")] +fn compute_boot_digest( + entry: &UsrLibModulesVmlinuz, + repo: &ComposefsRepository, +) -> Result { + let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + let initramfs = read_file(initramfs, &repo).context("Reading intird")?; + + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) + .context("Creating hasher")?; + + hasher.update(&vmlinuz).context("hashing vmlinuz")?; + hasher.update(&initramfs).context("hashing initrd")?; + + let digest: &[u8] = &hasher.finish().context("Finishing digest")?; + + return Ok(hex::encode(digest)); +} + +/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum +/// +/// # Returns +/// Returns the verity of the deployment that has a boot digest same as the one passed in +#[context("Checking boot entry duplicates")] +fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { + let deployments = + cap_std::fs::Dir::open_ambient_dir(STATE_DIR_ABS, cap_std::ambient_authority()); + + let deployments = match deployments { + Ok(d) => d, + // The first ever deployment + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => anyhow::bail!(e), + }; + + let mut symlink_to: Option = None; + + for depl in deployments.entries()? { + let depl = depl?; + + let depl_file_name = depl.file_name(); + let depl_file_name = depl_file_name.as_str()?; + + let config = depl + .open_dir() + .with_context(|| format!("Opening {depl_file_name}"))? + .read_to_string(format!("{depl_file_name}.origin")) + .context("Reading origin file")?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + + match ini.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST) { + Some(hash) => { + if hash == digest { + symlink_to = Some(depl_file_name.to_string()); + break; + } + } + + // No SHASum recorded in origin file + // `symlink_to` is already none, but being explicit here + None => symlink_to = None, + }; + } + + Ok(symlink_to) +} + +#[context("Writing BLS entries to disk")] +fn write_bls_boot_entries_to_disk( + boot_dir: &Utf8PathBuf, + deployment_id: &Sha256HashValue, + entry: &UsrLibModulesVmlinuz, + repo: &ComposefsRepository, +) -> Result<()> { + let id_hex = deployment_id.to_hex(); + + // Write the initrd and vmlinuz at /boot// + let path = boot_dir.join(&id_hex); + create_dir_all(&path)?; + + let entries_dir = cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) + .with_context(|| format!("Opening {path}"))?; + + entries_dir + .atomic_write( + "vmlinuz", + read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?, + ) + .context("Writing vmlinuz to path")?; + + let Some(initramfs) = &entry.initramfs else { + anyhow::bail!("initramfs not found"); + }; + + entries_dir + .atomic_write( + "initrd", + read_file(initramfs, &repo).context("Reading initrd")?, + ) + .context("Writing initrd to path")?; + + // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd + let owned_fd = entries_dir + .reopen_as_ownedfd() + .context("Reopen as owned fd")?; + + rustix::fs::fsync(owned_fd).context("fsync")?; + + Ok(()) +} + +/// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk +/// +/// # Returns +/// Returns the SHA256Sum of VMLinuz + Initrd combo. Error if any #[context("Setting up BLS boot")] pub(crate) fn setup_composefs_bls_boot( setup_type: BootSetupType, @@ -1570,7 +1698,7 @@ pub(crate) fn setup_composefs_bls_boot( repo: ComposefsRepository, id: &Sha256HashValue, entry: ComposefsBootEntry, -) -> Result<()> { +) -> Result { let id_hex = id.to_hex(); let (root_path, cmdline_refs) = match setup_type { @@ -1602,59 +1730,38 @@ pub(crate) fn setup_composefs_bls_boot( }; let boot_dir = root_path.join("boot"); + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade); - let bls_config = match &entry { + let (bls_config, boot_digest) = match &entry { ComposefsBootEntry::Type1(..) => unimplemented!(), ComposefsBootEntry::Type2(..) => unimplemented!(), ComposefsBootEntry::UsrLibModulesUki(..) => unimplemented!(), ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { - // Write the initrd and vmlinuz at /boot// - let path = boot_dir.join(&id_hex); - create_dir_all(&path)?; - - let entries_dir = - cap_std::fs::Dir::open_ambient_dir(&path, cap_std::ambient_authority()) - .with_context(|| format!("Opening {path}"))?; - - entries_dir - .atomic_write( - "vmlinuz", - read_file(&usr_lib_modules_vmlinuz.vmlinuz, &repo) - .context("Reading vmlinuz")?, - ) - .context("Writing vmlinuz to path")?; - - if let Some(initramfs) = &usr_lib_modules_vmlinuz.initramfs { - entries_dir - .atomic_write( - "initrd", - read_file(initramfs, &repo).context("Reading initrd")?, - ) - .context("Writing initrd to path")?; - } else { - anyhow::bail!("initramfs not found"); - }; + let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) + .context("Computing boot digest")?; - // 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")?; - - 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 @@ -1687,7 +1794,7 @@ pub(crate) fn setup_composefs_bls_boot( .context("Reopening as owned fd")?; rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?; - Ok(()) + Ok(boot_digest) } pub fn get_esp_partition(device: &str) -> Result<(String, Option)> { @@ -1986,14 +2093,19 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - }; let boot_type = BootType::from(&entry); + let mut boot_digest: Option = None; match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state)), - repo, - &id, - entry, - )?, + BootType::Bls => { + let digest = setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state)), + repo, + &id, + entry, + )?; + + boot_digest = Some(digest); + } BootType::Uki => setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state)), repo, @@ -2012,6 +2124,7 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - }, false, boot_type, + boot_digest, )?; Ok(()) @@ -2020,11 +2133,16 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - 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"; -/// Relative to /sysroot + +/// 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"; pub(crate) const ORIGIN_KEY_BOOT: &str = "boot"; 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"; /// Creates and populates /sysroot/state/deploy/image_id #[context("Writing composefs state")] @@ -2034,6 +2152,7 @@ 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())); @@ -2061,6 +2180,12 @@ pub(crate) fn write_composefs_state( .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")?; From f7875753257b613cafe9aabf2e1fd86e0aa3f576 Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Mon, 28 Jul 2025 15:26:11 +0530 Subject: [PATCH 08/20] parser/bls: `impl Display` for BLSConfig Signed-off-by: Johan-Liebert1 --- crates/lib/src/bls_config.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/lib/src/bls_config.rs b/crates/lib/src/bls_config.rs index 16ffceb78..67b0a2cd3 100644 --- a/crates/lib/src/bls_config.rs +++ b/crates/lib/src/bls_config.rs @@ -2,6 +2,7 @@ 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 { @@ -34,24 +35,22 @@ impl Ord for BLSConfig { } } -impl BLSConfig { - pub(crate) fn to_string(&self) -> String { - let mut out = String::new(); - +impl Display for BLSConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(title) = &self.title { - out += &format!("title {}\n", title); + writeln!(f, "title {}", 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); + 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 { - out += &format!("{} {}\n", key, value); + writeln!(f, "{} {}", key, value)?; } - out + Ok(()) } } From 059335b5cb7756fa16b97a1229a6415981fbb65c Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Tue, 29 Jul 2025 10:08:59 +0530 Subject: [PATCH 09/20] lib/composefs: Centralize constants Centralize all constants in a separate file Signed-off-by: Johan-Liebert1 --- crates/lib/src/composefs_consts.rs | 33 ++++++++++++++++++++++++++ crates/lib/src/deploy.rs | 34 +++++++++++++------------- crates/lib/src/install.rs | 38 +++++++++++++----------------- crates/lib/src/lib.rs | 1 + crates/lib/src/status.rs | 11 ++++----- 5 files changed, 71 insertions(+), 46 deletions(-) create mode 100644 crates/lib/src/composefs_consts.rs diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs new file mode 100644 index 000000000..0cad8db69 --- /dev/null +++ b/crates/lib/src/composefs_consts.rs @@ -0,0 +1,33 @@ +/// 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"; + +/// 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 b6cff69c3..4868a4225 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -26,6 +26,9 @@ use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; use crate::bls_config::{parse_bls_config, BLSConfig}; +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::grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}; use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep}; @@ -747,11 +750,6 @@ pub(crate) async fn stage( Ok(()) } -/// Filename for `loader/entries` -pub(crate) const USER_CFG: &str = "user.cfg"; -pub(crate) const USER_CFG_STAGED: &str = "user.cfg.staged"; -pub(crate) const USER_CFG_ROLLBACK: &str = USER_CFG_STAGED; - #[context("Rolling back UKI")] pub(crate) fn rollback_composefs_uki() -> Result<()> { let user_cfg_path = PathBuf::from("/sysroot/boot/grub2"); @@ -804,14 +802,9 @@ pub(crate) fn rollback_composefs_uki() -> Result<()> { Ok(()) } -/// Filename for `loader/entries` -const CURRENT_ENTRIES: &str = "entries"; -const STAGED_ENTRIES: &str = "entries.staged"; -const ROLLBACK_ENTRIES: &str = STAGED_ENTRIES; - // Need str to store lifetime pub(crate) fn get_sorted_uki_boot_entries<'a>(str: &'a mut String) -> Result>> { - let mut file = std::fs::File::open("/sysroot/boot/grub2/user.cfg")?; + let mut file = std::fs::File::open(format!("/sysroot/boot/grub2/{USER_CFG}"))?; file.read_to_string(str)?; parse_grub_menuentry_file(str) } @@ -820,7 +813,7 @@ pub(crate) fn get_sorted_uki_boot_entries<'a>(str: &'a mut String) -> Result Result> { let mut all_configs = vec![]; - for entry in std::fs::read_dir(format!("/sysroot/boot/loader/{CURRENT_ENTRIES}"))? { + for entry in std::fs::read_dir(format!("/sysroot/boot/loader/{BOOT_LOADER_ENTRIES}"))? { let entry = entry?; let file_name = entry.file_name(); @@ -863,7 +856,9 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { 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 = @@ -891,18 +886,21 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { let dir = Dir::open_ambient_dir("/sysroot/boot/loader", cap_std::ambient_authority()) .context("Opening loader dir")?; - tracing::debug!("Atomically exchanging for {ROLLBACK_ENTRIES} and {CURRENT_ENTRIES}"); + tracing::debug!( + "Atomically exchanging for {ROLLBACK_BOOT_LOADER_ENTRIES} and {BOOT_LOADER_ENTRIES}" + ); renameat_with( &dir, - ROLLBACK_ENTRIES, + ROLLBACK_BOOT_LOADER_ENTRIES, &dir, - CURRENT_ENTRIES, + BOOT_LOADER_ENTRIES, RenameFlags::EXCHANGE, ) .context("renameat")?; - tracing::debug!("Removing {ROLLBACK_ENTRIES}"); - rustix::fs::unlinkat(&dir, ROLLBACK_ENTRIES, AtFlags::empty()).context("unlinkat")?; + tracing::debug!("Removing {ROLLBACK_BOOT_LOADER_ENTRIES}"); + rustix::fs::unlinkat(&dir, ROLLBACK_BOOT_LOADER_ENTRIES, AtFlags::empty()) + .context("unlinkat")?; tracing::debug!("Syncing to disk"); fsync( diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 44b3b2553..6ace3e049 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -76,10 +76,15 @@ use serde::{Deserialize, Serialize}; use self::baseline::InstallBlockDeviceOpts; use crate::bls_config::{parse_bls_config, BLSConfig}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; +use crate::composefs_consts::{ + BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, + STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, STATE_DIR_RELATIVE, USER_CFG, USER_CFG_STAGED, +}; use crate::containerenv::ContainerExecutionInfo; use crate::deploy::{ get_sorted_uki_boot_entries, prepare_for_pull, pull_from_prepared, PreparedImportMeta, - PreparedPullResult, USER_CFG, USER_CFG_STAGED, + PreparedPullResult, }; use crate::kernel_cmdline::Cmdline; use crate::lsm; @@ -1496,7 +1501,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)?; @@ -1518,7 +1523,7 @@ async fn initialize_composefs_repository( fn get_booted_bls() -> Result { let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; let booted = cmdline - .find_str("composefs") + .find_str(COMPOSEFS_CMDLINE) .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; for entry in std::fs::read_dir("/sysroot/boot/loader/entries")? { @@ -1708,10 +1713,10 @@ pub(crate) fn setup_composefs_bls_boot( match &state.composefs_options { Some(opt) if opt.insecure => { - cmdline_options.push_str(&format!(" composefs=?{id_hex}")); + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}=?{id_hex}")); } None | Some(..) => { - cmdline_options.push_str(&format!(" composefs={id_hex}")); + cmdline_options.push_str(&format!(" {COMPOSEFS_CMDLINE}={id_hex}")); } }; @@ -1723,7 +1728,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(" "), ), @@ -1766,9 +1771,12 @@ pub(crate) fn setup_composefs_bls_boot( 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))?; @@ -2130,20 +2138,6 @@ fn setup_composefs_boot(root_setup: &RootSetup, state: &State, image_id: &str) - Ok(()) } -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"; - -pub(crate) const ORIGIN_KEY_BOOT: &str = "boot"; -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"; - /// Creates and populates /sysroot/state/deploy/image_id #[context("Writing composefs state")] pub(crate) fn write_composefs_state( diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 31b51003e..7ef6fa8a4 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -9,6 +9,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/status.rs b/crates/lib/src/status.rs index d650172c8..cea89fd2b 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -28,14 +28,13 @@ 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::install::ORIGIN_KEY_BOOT; -use crate::install::ORIGIN_KEY_BOOT_TYPE; -use crate::install::{ - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, -}; use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; use crate::spec::{ImageReference, ImageSignature}; @@ -463,7 +462,7 @@ async fn boot_entry_from_composefs_deployment( pub(crate) async fn composefs_deployment_status() -> Result { let cmdline = crate::kernel_cmdline::Cmdline::from_proc()?; let composefs_arg = cmdline - .find_str("composefs") + .find_str(COMPOSEFS_CMDLINE) .ok_or_else(|| anyhow::anyhow!("Failed to find composefs parameter in kernel cmdline"))?; let booted_image_verity = composefs_arg .value From dd4b41e85a96d0af24fb8c5b9d79250285169589 Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Tue, 29 Jul 2025 11:09:11 +0530 Subject: [PATCH 10/20] composefs/state: Name state directory `default` Instead of `/sysroot/state/os/fedora` use `/sysroot/state/os/default` as the default state directory. Signed-off-by: Johan-Liebert1 --- crates/lib/src/composefs_consts.rs | 2 ++ crates/lib/src/install.rs | 15 +++++++---- crates/lib/src/utils.rs | 42 +++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 0cad8db69..352eab0fa 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -10,6 +10,8 @@ pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_FNAME: &str = "staged-deployment"; 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"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 6ace3e049..9ffb7ee2e 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -79,7 +79,8 @@ 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, - STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, STATE_DIR_RELATIVE, USER_CFG, USER_CFG_STAGED, + 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::{ @@ -93,7 +94,7 @@ 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 @@ -2153,11 +2154,15 @@ pub(crate) fn write_composefs_state( 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, diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index 39adc5ef8..aefad5233 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" + ); + } } From 645c93b999eb5680399a54b6d236160fd1aa354c Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Tue, 29 Jul 2025 11:37:26 +0530 Subject: [PATCH 11/20] parser/bls: Add tests for bls parser Signed-off-by: Johan-Liebert1 --- crates/lib/src/bls_config.rs | 1 + crates/lib/src/deploy.rs | 7 +++--- crates/lib/src/install.rs | 36 +++++++++++++++------------- crates/lib/src/lib.rs | 3 ++- crates/lib/src/parsers/bls_config.rs | 2 +- crates/lib/src/status.rs | 4 +++- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/lib/src/bls_config.rs b/crates/lib/src/bls_config.rs index 67b0a2cd3..7db1b56d2 100644 --- a/crates/lib/src/bls_config.rs +++ b/crates/lib/src/bls_config.rs @@ -66,6 +66,7 @@ where } } +#[allow(dead_code)] pub(crate) fn parse_bls_config(input: &str) -> Result { let mut map = HashMap::new(); diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 4868a4225..50ecc615a 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -25,11 +25,11 @@ use ostree_ext::sysroot::SysrootLock; use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten; use rustix::fs::{fsync, renameat_with, AtFlags, RenameFlags}; -use crate::bls_config::{parse_bls_config, BLSConfig}; 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; @@ -849,7 +849,7 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { // Update the indicies so that they're swapped for (idx, cfg) in all_configs.iter_mut().enumerate() { - cfg.version = idx as u32; + cfg.sort_key = Some(idx.to_string()); } // TODO(Johan-Liebert): Currently assuming there are only two deployments @@ -867,7 +867,8 @@ pub(crate) fn rollback_composefs_bls() -> Result<()> { // Write the BLS configs in there for cfg in all_configs { - let file_name = format!("bootc-composefs-{}.conf", cfg.version); + // SAFETY: We set sort_key above + let file_name = format!("bootc-composefs-{}.conf", cfg.sort_key.as_ref().unwrap()); rollback_entries_dir .atomic_write(&file_name, cfg.to_string()) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 9ffb7ee2e..6b76004c0 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -57,7 +57,6 @@ 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; @@ -74,7 +73,6 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "install-to-disk")] use self::baseline::InstallBlockDeviceOpts; -use crate::bls_config::{parse_bls_config, BLSConfig}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; use crate::composefs_consts::{ BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, @@ -89,6 +87,7 @@ use crate::deploy::{ }; 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; @@ -1536,8 +1535,11 @@ fn get_booted_bls() -> Result { let bls = parse_bls_config(&std::fs::read_to_string(&entry.path())?)?; - // TODO clean this up - if bls.options.contains(booted.as_ref()) { + let Some(opts) = &bls.options else { + anyhow::bail!("options not found in bls config") + }; + + if opts.contains(booted.as_ref()) { return Ok(bls); } } @@ -1747,18 +1749,18 @@ pub(crate) fn setup_composefs_bls_boot( 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(), - }; + let mut bls_config = BLSConfig::default(); + bls_config.title = Some(id_hex.clone()); + bls_config.sort_key = Some("1".into()); + bls_config.machine_id = None; + bls_config.linux = format!("/boot/{id_hex}/vmlinuz"); + bls_config.initrd = vec![format!("/boot/{id_hex}/initrd")]; + bls_config.options = Some(cmdline_refs); + bls_config.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"); + bls_config.initrd = vec![format!("/boot/{symlink_to}/initrd")]; } else { write_bls_boot_entries_to_disk(&boot_dir, id, usr_lib_modules_vmlinuz, &repo)?; } @@ -1769,7 +1771,7 @@ pub(crate) fn setup_composefs_bls_boot( 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 + booted_bls.sort_key = Some("0".into()); // entries are sorted by their filename in reverse order // This will be atomically renamed to 'loader/entries' on shutdown/reboot ( @@ -1787,13 +1789,15 @@ pub(crate) fn setup_composefs_bls_boot( .with_context(|| format!("Opening {entries_path}"))?; loader_entries_dir.atomic_write( - format!("bootc-composefs-{}.conf", bls_config.version), + // SAFETY: We set sort_key above + format!("bootc-composefs-{}.conf", bls_config.sort_key.as_ref().unwrap()), bls_config.to_string().as_bytes(), )?; if let Some(booted_bls) = booted_bls { loader_entries_dir.atomic_write( - format!("bootc-composefs-{}.conf", booted_bls.version), + // SAFETY: We set sort_key above + format!("bootc-composefs-{}.conf", booted_bls.sort_key.as_ref().unwrap()), booted_bls.to_string().as_bytes(), )?; } diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 7ef6fa8a4..0df459879 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -4,7 +4,6 @@ //! to provide a fully "container native" tool for using //! bootable container images. -mod bls_config; pub(crate) mod bootc_kargs; mod boundimage; mod cfsctl; @@ -44,5 +43,7 @@ pub(crate) mod parsers; #[cfg(feature = "rhsm")] mod rhsm; +mod parsers; + // Re-export blockdev crate for internal use pub(crate) use bootc_blockdev as blockdev; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 2f9ff34f6..0219fba53 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -12,7 +12,7 @@ use uapi_version::Version; /// The boot loader should present the available boot menu entries to the user in a sorted list. /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, Default)] #[non_exhaustive] pub(crate) struct BLSConfig { /// The title of the boot entry, to be displayed in the boot menu. diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index cea89fd2b..3361342e6 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -452,7 +452,7 @@ async fn boot_entry_from_composefs_deployment( store: None, ostree: None, composefs: Some(crate::spec::BootEntryComposefs { verity, boot_type }), - soft_reboot_capable: false + soft_reboot_capable: false, }; return Ok(e); @@ -558,6 +558,8 @@ pub(crate) async fn composefs_deployment_status() -> Result { .first() .ok_or(anyhow::anyhow!("First boot entry not found"))? .options + .as_ref() + .ok_or(anyhow::anyhow!("options key not found in bls config"))? .contains(composefs_arg.as_ref()); } From 7a095468e62b025bc39e2e135fb373393d59b88f Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Thu, 31 Jul 2025 11:35:19 +0530 Subject: [PATCH 12/20] install/composefs/uki: Write only staged + booted menuentry on upgrade Instaed of writing all present menuentries, only write the menuentry for switch/upgrade and the menuentry for the currently booted deployment. Signed-off-by: Johan-Liebert1 --- crates/lib/src/install.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 6b76004c0..dcb3b2907 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1995,9 +1995,10 @@ pub(crate) fn setup_composefs_uki_boot( let mut str_buf = String::new(); let entries = get_sorted_uki_boot_entries(&mut str_buf)?; - for entry in entries { - buffer.write_all(entry.to_string().as_bytes())?; - } + // Write out only the currently booted entry, which should be the very first one + // Even if we have booted into the second menuentry "boot entry", the default will be the + // first one + buffer.write_all(entries[0].to_string().as_bytes())?; grub_dir .atomic_write(user_cfg_name, buffer) From b93a14b59133904e9aa016ee2f1ba8a84e7e4c93 Mon Sep 17 00:00:00 2001 From: Johan-Liebert1 Date: Thu, 31 Jul 2025 11:44:01 +0530 Subject: [PATCH 13/20] rollback/composefs: Print whether we are reverting the queued rollback Signed-off-by: Johan-Liebert1 --- crates/lib/src/deploy.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 50ecc615a..5730f19f0 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -948,6 +948,12 @@ pub(crate) async fn composefs_rollback() -> Result<()> { BootType::Uki => rollback_composefs_uki(), }?; + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + Ok(()) } From aefb8ee961e7bcd16472866c2cc774439130776c Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 1 Aug 2025 15:10:58 +0530 Subject: [PATCH 14/20] refactor: Pass boot dir to boot entry readers This allows for easier testing Signed-off-by: Pragyan Poudyal --- crates/lib/src/deploy.rs | 41 +++++++++++++++++++++++++++++---------- crates/lib/src/install.rs | 4 +++- crates/lib/src/status.rs | 6 ++++-- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 5730f19f0..ba3059ca1 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -26,7 +26,8 @@ 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, + 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}; @@ -755,8 +756,11 @@ 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(&mut str).context("Getting UKI boot entries")?; + 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); @@ -803,17 +807,25 @@ pub(crate) fn rollback_composefs_uki() -> Result<()> { } // Need str to store lifetime -pub(crate) fn get_sorted_uki_boot_entries<'a>(str: &'a mut String) -> Result>> { - let mut file = std::fs::File::open(format!("/sysroot/boot/grub2/{USER_CFG}"))?; +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_bls_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/{BOOT_LOADER_ENTRIES}"))? { + for entry in boot_dir.read_dir(format!("loader/{BOOT_LOADER_ENTRIES}"))? { let entry = entry?; let file_name = entry.file_name(); @@ -826,8 +838,13 @@ pub(crate) fn get_sorted_bls_boot_entries(ascending: bool) -> Result Result 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(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() { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index dcb3b2907..4b2efab8c 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1993,7 +1993,9 @@ pub(crate) fn setup_composefs_uki_boot( )?; let mut str_buf = String::new(); - let entries = get_sorted_uki_boot_entries(&mut str_buf)?; + 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 diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 3361342e6..9ce521e5d 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -552,9 +552,11 @@ pub(crate) async fn composefs_deployment_status() -> Result { 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(false)? + host.status.rollback_queued = !get_sorted_bls_boot_entries(&boot_dir, false)? .first() .ok_or(anyhow::anyhow!("First boot entry not found"))? .options @@ -566,7 +568,7 @@ pub(crate) async fn composefs_deployment_status() -> Result { BootType::Uki => { let mut s = String::new(); - host.status.rollback_queued = !get_sorted_uki_boot_entries(&mut s)? + host.status.rollback_queued = !get_sorted_uki_boot_entries(&boot_dir, &mut s)? .first() .ok_or(anyhow::anyhow!("First boot entry not found"))? .body From 969965c971d52e2c8cfa65257c4011f2c6a08b10 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 1 Aug 2025 15:35:58 +0530 Subject: [PATCH 15/20] test: Add tests for reading boot entries Add tests for functions `get_sorted_bls_boot_entries` and `get_sorted_uki_boot_entries` Signed-off-by: Pragyan Poudyal --- crates/lib/src/deploy.rs | 118 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index ba3059ca1..c026c450b 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -26,8 +26,7 @@ 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, + 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}; @@ -1189,6 +1188,8 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { #[cfg(test)] mod tests { + use crate::parsers::grub_menuconfig::MenuentryBody; + use super::*; #[test] @@ -1283,4 +1284,117 @@ UUID=6907-17CA /boot/efi vfat umask=0077,shortname=win assert_eq!(tempdir.read_to_string("etc/fstab")?, modified); Ok(()) } + + #[test] + fn test_sorted_bls_boot_entries() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let entry1 = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version fedora-42.0 + sort-key 1 + linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 + initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 + "#; + + let entry2 = r#" + title Fedora 41.20250214.2.0 (CoreOS) + version fedora-42.0 + sort-key 2 + linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10 + initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01 + "#; + + tempdir.create_dir_all("loader/entries")?; + tempdir.atomic_write( + "loader/entries/random_file.txt", + "Random file that we won't parse", + )?; + tempdir.atomic_write("loader/entries/entry1.conf", entry1)?; + tempdir.atomic_write("loader/entries/entry2.conf", entry2)?; + + let result = get_sorted_bls_boot_entries(&tempdir, true).unwrap(); + + let mut config1 = BLSConfig::default(); + config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into()); + config1.sort_key = Some("1".into()); + config1.linux = "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(); + config1.initrd = vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()]; + config1.options = Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()); + + let mut config2 = BLSConfig::default(); + config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into()); + config2.sort_key = Some("2".into()); + config2.linux = "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(); + config2.initrd = vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()]; + config2.options = Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into()); + + assert_eq!(result[0].sort_key.as_ref().unwrap(), "1"); + assert_eq!(result[1].sort_key.as_ref().unwrap(), "2"); + + let result = get_sorted_bls_boot_entries(&tempdir, false).unwrap(); + assert_eq!(result[0].sort_key.as_ref().unwrap(), "2"); + assert_eq!(result[1].sort_key.as_ref().unwrap(), "1"); + + Ok(()) + } + + #[test] + fn test_sorted_uki_boot_entries() -> Result<()> { + let user_cfg = r#" + if [ -f ${config_directory}/efiuuid.cfg ]; then + source ${config_directory}/efiuuid.cfg + fi + + menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi + } + + menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" { + insmod fat + insmod chain + search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}" + chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi + } + "#; + + let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + bootdir.create_dir_all(format!("grub2"))?; + bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?; + + let mut s = String::new(); + let result = get_sorted_uki_boot_entries(&bootdir, &mut s)?; + + let expected = vec![ + MenuEntry { + title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + MenuEntry { + title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(), + body: MenuentryBody { + insmod: vec!["fat", "chain"], + chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(), + search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", + version: 0, + extra: vec![], + }, + }, + ]; + + assert_eq!(result, expected); + + Ok(()) + } } From 1137bd3b1ca39a080853e428ac65f9acfcfd0bef Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 12 Aug 2025 12:05:08 +0200 Subject: [PATCH 16/20] Drop duplicate bls_config --- crates/lib/src/bls_config.rs | 88 ------------------------------------ 1 file changed, 88 deletions(-) delete mode 100644 crates/lib/src/bls_config.rs diff --git a/crates/lib/src/bls_config.rs b/crates/lib/src/bls_config.rs deleted file mode 100644 index 7db1b56d2..000000000 --- a/crates/lib/src/bls_config.rs +++ /dev/null @@ -1,88 +0,0 @@ -use anyhow::Result; -use serde::de::Error; -use serde::{Deserialize, Deserializer}; -use std::collections::HashMap; -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 { - Some(self.cmp(other)) - } -} - -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")), - } -} - -#[allow(dead_code)] -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) -} From 1a7ac13ef104c32e18c1e28fbee236bf061fc235 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 13 Aug 2025 19:34:52 +0200 Subject: [PATCH 17/20] install: Use read_file from composefs-boot Just reducing code here. --- crates/lib/src/install.rs | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 4b2efab8c..f4e3fb902 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -16,7 +16,7 @@ pub(crate) mod osconfig; use std::collections::HashMap; use std::fs::create_dir_all; -use std::io::{Read, Write}; +use std::io::Write; use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::fs::symlink; use std::os::unix::process::CommandExt; @@ -41,9 +41,9 @@ use cap_std_ext::cap_tempfile::TempDir; use cap_std_ext::cmdext::CapStdExtCommandExt; use cap_std_ext::prelude::CapStdExtDirExt; use clap::ValueEnum; +use composefs_boot::bootloader::read_file; use fn_error_context::context; use ostree::gio; -use ostree_ext::composefs::tree::RegularFile; use ostree_ext::composefs::{ fsverity::{FsVerityHashValue, Sha256HashValue}, repository::Repository as ComposefsRepository, @@ -1547,24 +1547,6 @@ fn get_booted_bls() -> Result { Err(anyhow::anyhow!("Booted BLS not found")) } -pub fn read_file( - file: &RegularFile, - repo: &ComposefsRepository, -) -> Result> { - match file { - RegularFile::Inline(data) => Ok(data.clone()), - RegularFile::External(id, size) => { - let mut data = vec![]; - std::fs::File::from(repo.open_object(id)?).read_to_end(&mut data)?; - ensure!( - *size == data.len() as u64, - "File content doesn't have the expected length" - ); - Ok(data.into_boxed_slice()) - } - } -} - pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk Setup((&'a RootSetup, &'a State)), From d1ea513b3ad08c43d0e6fee79860f48375a52484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Ravier?= Date: Tue, 10 Jun 2025 13:19:36 +0200 Subject: [PATCH 18/20] examples: Add initial bootc examples (bls & uki) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Ravier --- examples/bootc-bls/Containerfile | 10 ++++ examples/bootc-bls/build | 17 ++++++ .../extra/etc/dracut.conf.d/no-xattr.conf | 1 + .../lib/dracut/dracut.conf.d/37composefs.conf | 6 +++ .../37composefs/composefs-setup-root.service | 34 ++++++++++++ .../modules.d/37composefs/module-setup.sh | 20 +++++++ examples/bootc-uki/Containerfile.stage1 | 10 ++++ examples/bootc-uki/Containerfile.stage2 | 33 ++++++++++++ examples/bootc-uki/build.base | 18 +++++++ examples/bootc-uki/build.final | 52 +++++++++++++++++++ .../extra/etc/dracut.conf.d/no-xattr.conf | 1 + .../lib/dracut/dracut.conf.d/37composefs.conf | 6 +++ .../37composefs/composefs-setup-root.service | 34 ++++++++++++ .../modules.d/37composefs/module-setup.sh | 20 +++++++ 14 files changed, 262 insertions(+) create mode 100644 examples/bootc-bls/Containerfile create mode 100755 examples/bootc-bls/build create mode 100644 examples/bootc-bls/extra/etc/dracut.conf.d/no-xattr.conf create mode 100644 examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf create mode 100644 examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service create mode 100755 examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh create mode 100644 examples/bootc-uki/Containerfile.stage1 create mode 100644 examples/bootc-uki/Containerfile.stage2 create mode 100755 examples/bootc-uki/build.base create mode 100755 examples/bootc-uki/build.final create mode 100644 examples/bootc-uki/extra/etc/dracut.conf.d/no-xattr.conf create mode 100644 examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf create mode 100644 examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service create mode 100755 examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh diff --git a/examples/bootc-bls/Containerfile b/examples/bootc-bls/Containerfile new file mode 100644 index 000000000..c6fbfcdbd --- /dev/null +++ b/examples/bootc-bls/Containerfile @@ -0,0 +1,10 @@ +FROM quay.io/fedora/fedora-bootc:42 +COPY extra / +COPY cfsctl /usr/bin + +RUN passwd -d root + +# need to have composefs setup root in the initramfs so we need this +RUN set -x; \ + kver=$(cd /usr/lib/modules && echo *); \ + dracut -vf --install "/etc/passwd /etc/group" /usr/lib/modules/$kver/initramfs.img $kver; diff --git a/examples/bootc-bls/build b/examples/bootc-bls/build new file mode 100755 index 000000000..3e3ec090c --- /dev/null +++ b/examples/bootc-bls/build @@ -0,0 +1,17 @@ +#!/bin/bash + +set -eux + +cd "${0%/*}" + +cargo build --release --features=pre-6.15 --bin cfsctl --bin composefs-setup-root + +cp ../../target/release/cfsctl . +cp ../../target/release/composefs-setup-root extra/usr/lib/dracut/modules.d/37composefs/ + +mkdir -p tmp + +sudo podman build \ + -t quay.io/fedora/fedora-bootc-bls:42 \ + -f Containerfile \ + --iidfile=tmp/iid \ diff --git a/examples/bootc-bls/extra/etc/dracut.conf.d/no-xattr.conf b/examples/bootc-bls/extra/etc/dracut.conf.d/no-xattr.conf new file mode 100644 index 000000000..b8d114a9c --- /dev/null +++ b/examples/bootc-bls/extra/etc/dracut.conf.d/no-xattr.conf @@ -0,0 +1 @@ +export DRACUT_NO_XATTR=1 diff --git a/examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf b/examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf new file mode 100644 index 000000000..1defe5de6 --- /dev/null +++ b/examples/bootc-bls/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf @@ -0,0 +1,6 @@ +# we want to make sure the virtio disk drivers get included +hostonly=no + +# we need to force these in via the initramfs because we don't have modules in +# the base image +force_drivers+=" virtio_net vfat " diff --git a/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service b/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service new file mode 100644 index 000000000..ffc404d68 --- /dev/null +++ b/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service @@ -0,0 +1,34 @@ +# Copyright (C) 2013 Colin Walters +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +[Unit] +DefaultDependencies=no +ConditionKernelCommandLine=composefs +ConditionPathExists=/etc/initrd-release +After=sysroot.mount +Requires=sysroot.mount +Before=initrd-root-fs.target +Before=initrd-switch-root.target + +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/bin/composefs-setup-root +StandardInput=null +StandardOutput=journal +StandardError=journal+console +RemainAfterExit=yes diff --git a/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh b/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh new file mode 100755 index 000000000..7fb853033 --- /dev/null +++ b/examples/bootc-bls/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/bash + +check() { + return 0 +} + +depends() { + return 0 +} + +install() { + inst \ + "${moddir}/composefs-setup-root" /usr/bin/composefs-setup-root + inst \ + "${moddir}/composefs-setup-root.service" \ + "${systemdsystemunitdir}/composefs-setup-root.service" + + $SYSTEMCTL -q --root "${initdir}" add-wants \ + 'initrd-root-fs.target' 'composefs-setup-root.service' +} diff --git a/examples/bootc-uki/Containerfile.stage1 b/examples/bootc-uki/Containerfile.stage1 new file mode 100644 index 000000000..c6fbfcdbd --- /dev/null +++ b/examples/bootc-uki/Containerfile.stage1 @@ -0,0 +1,10 @@ +FROM quay.io/fedora/fedora-bootc:42 +COPY extra / +COPY cfsctl /usr/bin + +RUN passwd -d root + +# need to have composefs setup root in the initramfs so we need this +RUN set -x; \ + kver=$(cd /usr/lib/modules && echo *); \ + dracut -vf --install "/etc/passwd /etc/group" /usr/lib/modules/$kver/initramfs.img $kver; diff --git a/examples/bootc-uki/Containerfile.stage2 b/examples/bootc-uki/Containerfile.stage2 new file mode 100644 index 000000000..99c368bb3 --- /dev/null +++ b/examples/bootc-uki/Containerfile.stage2 @@ -0,0 +1,33 @@ +FROM quay.io/fedora/fedora-bootc-base-uki:42 AS base + +FROM base as kernel + +ARG COMPOSEFS_FSVERITY + +RUN < /etc/kernel/cmdline + + dnf install -y systemd-ukify; + kver=$(cd /usr/lib/modules && echo *); + ukify build \ + --linux /usr/lib/modules/$kver/vmlinuz \ + --initrd /usr/lib/modules/$kver/initramfs.img \ + --cmdline "@/etc/kernel/cmdline" \ + --output /boot/$kver.efi +EOF + +FROM base as final + +RUN --mount=type=bind,from=kernel,target=/_mount/kernel < /dev/null +# uuidgen --random > GUID.txt +# openssl req -newkey rsa:4096 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Platform Key/" -out PK.crt +# openssl x509 -outform DER -in PK.crt -out PK.cer +# openssl req -newkey rsa:4096 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Key Exchange Key/" -out KEK.crt +# openssl x509 -outform DER -in KEK.crt -out KEK.cer +# openssl req -newkey rsa:4096 -nodes -keyout db.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Signature Database key/" -out db.crt +# openssl x509 -outform DER -in db.crt -out db.cer +# popd > /dev/null +# fi diff --git a/examples/bootc-uki/extra/etc/dracut.conf.d/no-xattr.conf b/examples/bootc-uki/extra/etc/dracut.conf.d/no-xattr.conf new file mode 100644 index 000000000..b8d114a9c --- /dev/null +++ b/examples/bootc-uki/extra/etc/dracut.conf.d/no-xattr.conf @@ -0,0 +1 @@ +export DRACUT_NO_XATTR=1 diff --git a/examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf b/examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf new file mode 100644 index 000000000..1defe5de6 --- /dev/null +++ b/examples/bootc-uki/extra/usr/lib/dracut/dracut.conf.d/37composefs.conf @@ -0,0 +1,6 @@ +# we want to make sure the virtio disk drivers get included +hostonly=no + +# we need to force these in via the initramfs because we don't have modules in +# the base image +force_drivers+=" virtio_net vfat " diff --git a/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service b/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service new file mode 100644 index 000000000..ffc404d68 --- /dev/null +++ b/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service @@ -0,0 +1,34 @@ +# Copyright (C) 2013 Colin Walters +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +[Unit] +DefaultDependencies=no +ConditionKernelCommandLine=composefs +ConditionPathExists=/etc/initrd-release +After=sysroot.mount +Requires=sysroot.mount +Before=initrd-root-fs.target +Before=initrd-switch-root.target + +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/bin/composefs-setup-root +StandardInput=null +StandardOutput=journal +StandardError=journal+console +RemainAfterExit=yes diff --git a/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh b/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh new file mode 100755 index 000000000..7fb853033 --- /dev/null +++ b/examples/bootc-uki/extra/usr/lib/dracut/modules.d/37composefs/module-setup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/bash + +check() { + return 0 +} + +depends() { + return 0 +} + +install() { + inst \ + "${moddir}/composefs-setup-root" /usr/bin/composefs-setup-root + inst \ + "${moddir}/composefs-setup-root.service" \ + "${systemdsystemunitdir}/composefs-setup-root.service" + + $SYSTEMCTL -q --root "${initdir}" add-wants \ + 'initrd-root-fs.target' 'composefs-setup-root.service' +} From 3873690955ecbe6f7a481c8ce448557690122c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Ravier?= Date: Wed, 11 Jun 2025 11:16:26 +0200 Subject: [PATCH 19/20] examples/bootc*: Secure Boot support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Ravier --- examples/bootc-uki/Containerfile.stage2 | 29 ++++++++++++----- examples/bootc-uki/build.final | 43 +++++++++++-------------- examples/bootc-uki/build_vars | 20 ++++++++++++ 3 files changed, 60 insertions(+), 32 deletions(-) create mode 100755 examples/bootc-uki/build_vars diff --git a/examples/bootc-uki/Containerfile.stage2 b/examples/bootc-uki/Containerfile.stage2 index 99c368bb3..964a6f2ae 100644 --- a/examples/bootc-uki/Containerfile.stage2 +++ b/examples/bootc-uki/Containerfile.stage2 @@ -4,29 +4,42 @@ FROM base as kernel ARG COMPOSEFS_FSVERITY -RUN < /etc/kernel/cmdline - dnf install -y systemd-ukify; - kver=$(cd /usr/lib/modules && echo *); + dnf install -y systemd-ukify sbsigntools systemd-boot-unsigned + kver=$(cd /usr/lib/modules && echo *) ukify build \ - --linux /usr/lib/modules/$kver/vmlinuz \ - --initrd /usr/lib/modules/$kver/initramfs.img \ + --linux "/usr/lib/modules/$kver/vmlinuz" \ + --initrd "/usr/lib/modules/$kver/initramfs.img" \ + --uname="${kver}" \ --cmdline "@/etc/kernel/cmdline" \ - --output /boot/$kver.efi + --os-release "@/etc/os-release" \ + --signtool sbsign \ + --secureboot-private-key "/run/secrets/key" \ + --secureboot-certificate "/run/secrets/cert" \ + --measure \ + --json pretty \ + --output "/boot/$kver.efi" + sbsign \ + --key "/run/secrets/key" \ + --cert "/run/secrets/cert" \ + "/usr/lib/systemd/boot/efi/systemd-bootx64.efi" \ + --output "/boot/systemd-bootx64.efi" EOF FROM base as final RUN --mount=type=bind,from=kernel,target=/_mount/kernel < /dev/null + uuidgen --random > GUID.txt + openssl req -newkey rsa:4096 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Platform Key/" -out PK.crt + openssl x509 -outform DER -in PK.crt -out PK.cer + openssl req -newkey rsa:4096 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Key Exchange Key/" -out KEK.crt + openssl x509 -outform DER -in KEK.crt -out KEK.cer + openssl req -newkey rsa:4096 -nodes -keyout db.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Signature Database key/" -out db.crt + openssl x509 -outform DER -in db.crt -out db.cer + popd > /dev/null +fi + +# For debugging, add --no-cache to podman command sudo podman build \ -t quay.io/fedora/fedora-bootc-uki:42 \ --build-arg=COMPOSEFS_FSVERITY="${COMPOSEFS_FSVERITY}" \ -f Containerfile.stage2 \ + --secret=id=key,src=secureboot/db.key \ + --secret=id=cert,src=secureboot/db.crt \ --iidfile=tmp/iid2 rm -rf tmp/efi @@ -26,27 +45,3 @@ mkdir -p tmp/efi ./cfsctl --repo tmp/sysroot/composefs oci pull containers-storage:"${IMAGE_ID}" ./cfsctl --repo tmp/sysroot/composefs oci compute-id --bootable "${IMAGE_ID}" ./cfsctl --repo tmp/sysroot/composefs oci prepare-boot "${IMAGE_ID}" --bootdir tmp/efi - -# For debugging, add --no-cache to podman command -# mkdir tmp/internal-sysroot -# # podman build \ -# --iidfile=tmp/iid \ -# -v "${PWD}/tmp/internal-sysroot:/tmp/sysroot:z,U" \ -# --secret=id=key,src=secureboot/db.key \ -# --secret=id=cert,src=secureboot/db.crt \ - -# See: https://wiki.archlinux.org/title/Unified_Extensible_Firmware_Interface/Secure_Boot -# Alternative to generate keys for testing: `sbctl create-keys` -# if [[ ! -d "secureboot" ]]; then -# echo "Generating test Secure Boot keys" -# mkdir secureboot -# pushd secureboot > /dev/null -# uuidgen --random > GUID.txt -# openssl req -newkey rsa:4096 -nodes -keyout PK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Platform Key/" -out PK.crt -# openssl x509 -outform DER -in PK.crt -out PK.cer -# openssl req -newkey rsa:4096 -nodes -keyout KEK.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Key Exchange Key/" -out KEK.crt -# openssl x509 -outform DER -in KEK.crt -out KEK.cer -# openssl req -newkey rsa:4096 -nodes -keyout db.key -new -x509 -sha256 -days 3650 -subj "/CN=Test Signature Database key/" -out db.crt -# openssl x509 -outform DER -in db.crt -out db.cer -# popd > /dev/null -# fi diff --git a/examples/bootc-uki/build_vars b/examples/bootc-uki/build_vars new file mode 100755 index 000000000..8008414b4 --- /dev/null +++ b/examples/bootc-uki/build_vars @@ -0,0 +1,20 @@ +#!/bin/bash + +set -eux + +cd "${0%/*}" + +if [[ ! -d "secureboot" ]]; then + echo "fail" + exit 1 +fi + +# See: https://github.com/rhuefi/qemu-ovmf-secureboot +# $ dnf install -y python3-virt-firmware +GUID=$(cat secureboot/GUID.txt) +virt-fw-vars --input "/usr/share/edk2/ovmf/OVMF_VARS_4M.secboot.qcow2" \ + --secure-boot \ + --set-pk $GUID "secureboot/PK.crt" \ + --add-kek $GUID "secureboot/KEK.crt" \ + --add-db $GUID "secureboot/db.crt" \ + -o "VARS_CUSTOM.secboot.qcow2.template" From c9aeee51395603c8dd7d75b46755849120eae062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Ravier?= Date: Wed, 11 Jun 2025 14:32:44 +0200 Subject: [PATCH 20/20] examples/bootc*: Temporary bootc install scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Ravier --- examples/bootc-uki/install-grub.sh | 29 ++++++++++++++ examples/bootc-uki/install-systemd-boot.sh | 45 ++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100755 examples/bootc-uki/install-grub.sh create mode 100755 examples/bootc-uki/install-systemd-boot.sh diff --git a/examples/bootc-uki/install-grub.sh b/examples/bootc-uki/install-grub.sh new file mode 100755 index 000000000..885826046 --- /dev/null +++ b/examples/bootc-uki/install-grub.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +set -eux + +curl http://192.168.122.1:8000/bootc -o bootc +chmod +x bootc + +IMAGE=quay.io/fedora/fedora-bootc-uki:42 + +# --env RUST_LOG=debug \ +# --env RUST_BACKTRACE=1 \ +podman run \ + --rm --privileged \ + --pid=host \ + -v /dev:/dev \ + -v /var/lib/containers:/var/lib/containers \ + -v /srv/bootc:/usr/bin/bootc:ro,Z \ + -v /var/tmp:/var/tmp \ + --security-opt label=type:unconfined_t \ + "${IMAGE}" \ + bootc install to-disk \ + --composefs-native \ + --boot=uki \ + --source-imgref="containers-storage:${IMAGE}" \ + --target-imgref="${IMAGE}" \ + --target-transport="docker" \ + /dev/vdb \ + --filesystem=ext4 \ + --wipe diff --git a/examples/bootc-uki/install-systemd-boot.sh b/examples/bootc-uki/install-systemd-boot.sh new file mode 100755 index 000000000..08e92107b --- /dev/null +++ b/examples/bootc-uki/install-systemd-boot.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -eux + +curl http://192.168.122.1:8000/bootc -o bootc +chmod +x bootc + +IMAGE=quay.io/fedora/fedora-bootc-uki:42 + +if [[ ! -f /srv/systemd-bootx64.efi ]]; then + echo "Needs /srv/systemd-bootx64.efi to exists for now" + exit 1 +fi + +# --env RUST_LOG=debug \ +# --env RUST_BACKTRACE=1 \ +podman run \ + --rm --privileged \ + --pid=host \ + -v /dev:/dev \ + -v /var/lib/containers:/var/lib/containers \ + -v /srv/bootc:/usr/bin/bootc:ro,Z \ + -v /var/tmp:/var/tmp \ + --security-opt label=type:unconfined_t \ + "${IMAGE}" \ + bootc install to-disk \ + --composefs-native \ + --boot=uki \ + --source-imgref="containers-storage:${IMAGE}" \ + --target-imgref="${IMAGE}" \ + --target-transport="docker" \ + /dev/vdb \ + --filesystem=ext4 \ + --wipe + +mkdir -p efi +mount /dev/vdb2 /srv/efi + +# Manual systemd-boot installation +cp /srv/systemd-bootx64.efi /srv/efi/EFI/fedora/grubx64.efi +mkdir -p /srv/efi/loader +echo "timeout 5" > /srv/efi/loader/loader.conf +rm -rf /srv/efi/EFI/fedora/grub.cfg + +umount efi