diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a87d3ea25..6797e5004 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,7 @@ jobs: # No fedora-44 due to https://bugzilla.redhat.com/show_bug.cgi?id=2429501 test_os: [fedora-43, centos-9, centos-10] variant: [ostree, composefs-sealeduki-sdboot, composefs-sdboot, composefs-grub] + filesystem: ["ext4", "xfs"] exclude: # centos-9 UKI is experimental/broken (https://github.com/bootc-dev/bootc/issues/1812) - test_os: centos-9 @@ -172,6 +173,10 @@ jobs: variant: composefs-sdboot - test_os: centos-9 variant: composefs-grub + # We only test filesystems for composefs to test if composefs backend will work on fs + # without fsverity + - variant: ostree + filesystem: ext4 runs-on: ubuntu-24.04 @@ -190,6 +195,7 @@ jobs: echo "BOOTC_base=${BASE}" >> $GITHUB_ENV echo "RUST_BACKTRACE=full" >> $GITHUB_ENV echo "RUST_LOG=trace" >> $GITHUB_ENV + echo "BOOTC_filesystem=${{ matrix.filesystem }}" >> $GITHUB_ENV case "${{ matrix.variant }}" in composefs-grub) @@ -213,8 +219,6 @@ jobs: ;; esac - - if [ "${{ matrix.variant }}" = "composefs-sealeduki-sdboot" ]; then BUILDROOTBASE=$(just pullspec-for-os buildroot-base ${{ matrix.test_os }}) echo "BOOTC_buildroot_base=${BUILDROOTBASE}" >> $GITHUB_ENV @@ -244,7 +248,7 @@ jobs: - name: Run TMT integration tests run: | if [[ "${{ matrix.variant }}" = composefs* ]]; then - just "test-${{ matrix.variant }}" + just "test-${{ matrix.variant }}" "${{ matrix.filesystem }}" else just test-tmt integration fi @@ -255,7 +259,7 @@ jobs: if: always() uses: actions/upload-artifact@v6 with: - name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-${{ matrix.variant }}-${{ env.ARCH }} + name: tmt-log-PR-${{ github.event.number }}-${{ matrix.test_os }}-${{ matrix.variant }}-${{ matrix.filesystem }}-${{ env.ARCH }} path: /var/tmp/tmt # Test bootc install on Fedora CoreOS (separate job to avoid disk space issues diff --git a/Dockerfile b/Dockerfile index 5422d8e06..2d32c49a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -176,6 +176,7 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp # We need our newly-built bootc for the compute-composefs-digest command FROM tools as sealed-uki ARG variant +ARG filesystem # Install our bootc package (only needed for the compute-composefs-digest command) RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packages,src=/,target=/run/packages \ @@ -186,8 +187,15 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=bind,from=packaging,src=/,target=/run/packaging \ --mount=type=bind,from=base-penultimate,src=/,target=/run/target < Result { /// Prepares a floating mount for composefs and returns the fd /// /// # Arguments -/// * sysroot - fd for /sysroot -/// * name - Name of the EROFS image to be mounted -/// * insecure - Whether fsverity is optional or not +/// * sysroot - fd for /sysroot +/// * name - Name of the EROFS image to be mounted +/// * allow_missing_fsverity - Whether to allow mount without fsverity support #[context("Mounting composefs image")] -pub fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Result { +pub fn mount_composefs_image( + sysroot: &OwnedFd, + name: &str, + allow_missing_fsverity: bool, +) -> Result { let mut repo = Repository::::open_path(sysroot, "composefs")?; - repo.set_insecure(insecure); + repo.set_insecure(allow_missing_fsverity); let rootfs = repo .mount(name) .context("Failed to mount composefs image")?; diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 918198029..f8720a934 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -94,7 +94,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; -use crate::parsers::grub_menuconfig::MenuEntry; use crate::task::Task; use crate::{ bootc_composefs::repo::get_imgref, @@ -119,6 +118,7 @@ use crate::{ }, spec::{Bootloader, Host}, }; +use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs}; use crate::install::{RootSetup, State}; @@ -155,7 +155,14 @@ pub(crate) enum BootSetupType<'a> { ), ), /// For `bootc upgrade` - Upgrade((&'a Storage, &'a ComposefsFilesystem, &'a Host)), + Upgrade( + ( + &'a Storage, + &'a BootedComposefs, + &'a ComposefsFilesystem, + &'a Host, + ), + ), } #[derive( @@ -512,7 +519,7 @@ pub(crate) fn setup_composefs_bls_boot( cmdline_options.extend(&root_setup.kargs); - let composefs_cmdline = if state.composefs_options.insecure { + let composefs_cmdline = if state.composefs_options.allow_missing_verity { format!("{COMPOSEFS_CMDLINE}=?{id_hex}") } else { format!("{COMPOSEFS_CMDLINE}={id_hex}") @@ -532,7 +539,7 @@ pub(crate) fn setup_composefs_bls_boot( ) } - BootSetupType::Upgrade((storage, fs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, fs, host)) => { let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -551,7 +558,12 @@ pub(crate) fn setup_composefs_bls_boot( }; // Copy all cmdline args, replacing only `composefs=` - let param = format!("{COMPOSEFS_CMDLINE}={id_hex}"); + let param = if booted_cfs.cmdline.allow_missing_fsverity { + format!("{COMPOSEFS_CMDLINE}=?{id_hex}") + } else { + format!("{COMPOSEFS_CMDLINE}={id_hex}") + }; + let param = Parameter::parse(¶m).context("Failed to create 'composefs=' parameter")?; cmdline.add_or_modify(¶m); @@ -799,7 +811,7 @@ fn write_pe_to_esp( file_path: &Utf8Path, pe_type: PEType, uki_id: &Sha512HashValue, - is_insecure_from_opts: bool, + missing_fsverity_allowed: bool, mounted_efi: impl AsRef, bootloader: &Bootloader, ) -> Result> { @@ -812,17 +824,19 @@ fn write_pe_to_esp( if matches!(pe_type, PEType::Uki) { let cmdline = uki::get_cmdline(&efi_bin).context("Getting UKI cmdline")?; - let (composefs_cmdline, insecure) = + let (composefs_cmdline, missing_verity_allowed_cmdline) = get_cmdline_composefs::(cmdline).context("Parsing composefs=")?; // If the UKI cmdline does not match what the user has passed as cmdline option // NOTE: This will only be checked for new installs and now upgrades/switches - match is_insecure_from_opts { - true if !insecure => { - tracing::warn!("--insecure passed as option but UKI cmdline does not support it"); + match missing_fsverity_allowed { + true if !missing_verity_allowed_cmdline => { + tracing::warn!( + "--allow-missing-fsverity passed as option but UKI cmdline does not support it" + ); } - false if insecure => { + false if missing_verity_allowed_cmdline => { tracing::warn!("UKI cmdline has composefs set as insecure"); } @@ -1068,7 +1082,8 @@ pub(crate) fn setup_composefs_uki_boot( id: &Sha512HashValue, entries: Vec>, ) -> Result { - let (root_path, esp_device, bootloader, is_insecure_from_opts, uki_addons) = match setup_type { + let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type + { BootSetupType::Setup((root_setup, state, postfetch, ..)) => { state.require_no_kargs_for_uki()?; @@ -1078,12 +1093,12 @@ pub(crate) fn setup_composefs_uki_boot( root_setup.physical_root_path.clone(), esp_part.node.clone(), postfetch.detected_bootloader.clone(), - state.composefs_options.insecure, + state.composefs_options.allow_missing_verity, state.composefs_options.uki_addon.as_ref(), ) } - BootSetupType::Upgrade((storage, _, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, _, host)) => { let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?; let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -1092,7 +1107,7 @@ pub(crate) fn setup_composefs_uki_boot( sysroot, get_esp_partition(&sysroot_parent)?.0, bootloader, - false, + booted_cfs.cmdline.allow_missing_fsverity, None, ) } @@ -1143,7 +1158,7 @@ pub(crate) fn setup_composefs_uki_boot( utf8_file_path, entry.pe_type, &id, - is_insecure_from_opts, + missing_fsverity_allowed, esp_mount.dir.path(), &bootloader, )?; @@ -1224,8 +1239,11 @@ pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, state: &State, image_id: &str, + allow_missing_fsverity: bool, ) -> Result<()> { - let repo = open_composefs_repo(&root_setup.physical_root)?; + let mut repo = open_composefs_repo(&root_setup.physical_root)?; + repo.set_insecure(allow_missing_fsverity); + let mut fs = create_composefs_filesystem(&repo, image_id, None)?; let entries = fs.transform_for_boot(&repo)?; let id = fs.commit_image(&repo, None)?; @@ -1296,6 +1314,7 @@ pub(crate) async fn setup_composefs_boot( &state.source.imageref.name, )) .await?, + allow_missing_fsverity, ) .await?; diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index f140122ed..9c409eef0 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -24,7 +24,11 @@ pub(crate) async fn get_etc_diff(storage: &Storage, booted_cfs: &BootedComposefs // Mount the booted EROFS image to get pristine etc let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; - let composefs_fd = mount_composefs_image(&sysroot_fd, &booted_composefs.verity, false)?; + let composefs_fd = mount_composefs_image( + &sysroot_fd, + &booted_composefs.verity, + booted_cfs.cmdline.allow_missing_fsverity, + )?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; @@ -68,7 +72,11 @@ pub(crate) async fn composefs_backend_finalize( // Mount the booted EROFS image to get pristine etc let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; - let composefs_fd = mount_composefs_image(&sysroot_fd, &booted_composefs.verity, false)?; + let composefs_fd = mount_composefs_image( + &sysroot_fd, + &booted_composefs.verity, + booted_cfs.cmdline.allow_missing_fsverity, + )?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 7f7ce9777..511a473da 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -23,12 +23,14 @@ pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result Result<(String, impl FsVerityHashValue)> { let rootfs_dir = &root_setup.physical_root; crate::store::ensure_composefs_dir(rootfs_dir)?; - let repo = open_composefs_repo(rootfs_dir)?; + let mut repo = open_composefs_repo(rootfs_dir)?; + repo.set_insecure(allow_missing_fsverity); let OstreeExtImgRef { name: image_name, @@ -71,6 +73,7 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { pub(crate) async fn pull_composefs_repo( transport: &String, image: &String, + allow_missing_fsverity: bool, ) -> Result<( crate::store::ComposefsRepository, Vec>, @@ -79,7 +82,8 @@ pub(crate) async fn pull_composefs_repo( )> { let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?; - let repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; + let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?; + repo.set_insecure(allow_missing_fsverity); let final_imgref = get_imgref(transport, image); @@ -91,7 +95,9 @@ pub(crate) async fn pull_composefs_repo( tracing::info!("ID: {id}, Verity: {}", verity.to_hex()); - let repo = open_composefs_repo(&rootfs_dir)?; + let mut repo = open_composefs_repo(&rootfs_dir)?; + repo.set_insecure(allow_missing_fsverity); + let mut fs: crate::store::ComposefsFilesystem = create_composefs_filesystem(&repo, &id, None) .context("Failed to create composefs filesystem")?; diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index f8af3a9ae..8bbe0ddc9 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -114,36 +114,34 @@ fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> { /// - Grub Type1 boot entries /// - Systemd Typ1 boot entries /// - Systemd UKI (Type2) boot entries [since we use BLS entries for systemd boot] +/// +/// Cases +/// 1. We're actually booted into the deployment that has it's sort_key as 0 +/// a. Just swap the primary and secondary bootloader entries +/// b. If they're already swapped (rollback was queued), re-swap them (unqueue rollback) +/// +/// 2. We're booted into the depl with sort_key 1 (choose the rollback deployment on boot screen) +/// a. Here we assume that rollback is queued as there's no way to differentiate between this +/// case and Case 1-b. This is what ostree does as well #[context("Rolling back {bootloader} entries")] fn rollback_composefs_entries(boot_dir: &Dir, bootloader: Bootloader) -> Result<()> { - use crate::bootc_composefs::state::get_booted_bls; - // Get all boot entries sorted in descending order by sort-key let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false)?; // TODO(Johan-Liebert): Currently assuming there are only two deployments assert!(all_configs.len() == 2); - // Identify which entry is the currently booted one - let booted_bls = get_booted_bls(&boot_dir)?; - let booted_verity = booted_bls.get_verity()?; - // For rollback: previous gets primary sort-key, booted gets secondary sort-key // Use "bootc" as default os_id for rollback scenarios // TODO: Extract actual os_id from deployment let os_id = "bootc"; - for cfg in &mut all_configs { - let cfg_verity = cfg.get_verity()?; - - if cfg_verity == booted_verity { - // This is the currently booted deployment - it should become secondary - cfg.sort_key = Some(secondary_sort_key(os_id)); - } else { - // This is the previous deployment - it should become primary (rollback target) - cfg.sort_key = Some(primary_sort_key(os_id)); - } - } + // This is the currently booted deployment - it should become secondary + // OR if rollback was queued, it would become primary + all_configs[0].sort_key = Some(primary_sort_key(os_id)); + // This is the previous deployment - it should become primary (rollback target) + // OR if rollback was queued, it would become secondary + all_configs[1].sort_key = Some(secondary_sort_key(os_id)); // Write these boot_dir @@ -156,9 +154,8 @@ fn rollback_composefs_entries(boot_dir: &Dir, bootloader: Bootloader) -> Result< // Write the BLS configs in there for cfg in all_configs { - let cfg_verity = cfg.get_verity()?; // After rollback: previous deployment becomes primary, booted becomes secondary - let priority = if cfg_verity == booted_verity { + let priority = if cfg.sort_key == Some(secondary_sort_key(os_id)) { FILENAME_PRIORITY_SECONDARY } else { FILENAME_PRIORITY_PRIMARY diff --git a/crates/lib/src/bootc_composefs/selinux.rs b/crates/lib/src/bootc_composefs/selinux.rs index 700275264..733f0897a 100644 --- a/crates/lib/src/bootc_composefs/selinux.rs +++ b/crates/lib/src/bootc_composefs/selinux.rs @@ -76,7 +76,8 @@ fn get_selinux_policy_for_deployment( let (deployment_root, _mount_guard) = if *booted_cmdline.digest == *depl_id { (Dir::open_ambient_dir("/", ambient_authority())?, None) } else { - let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, false)?; + let composefs_fd = + mount_composefs_image(&sysroot_fd, depl_id, booted_cmdline.allow_missing_fsverity)?; let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt)) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 0a71d15b9..b9e8a6e53 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -108,7 +108,11 @@ pub(crate) async fn prepare_soft_reboot_composefs( create_dir_all(NEXTROOT).context("Creating nextroot")?; - let cmdline = Cmdline::from(format!("{COMPOSEFS_CMDLINE}={deployment_id}")); + let cmdline = if booted_cfs.cmdline.allow_missing_fsverity { + Cmdline::from(format!("{COMPOSEFS_CMDLINE}=?{deployment_id}")) + } else { + Cmdline::from(format!("{COMPOSEFS_CMDLINE}={deployment_id}")) + }; let args = bootc_initramfs_setup::Args { cmd: vec![], diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 517281be0..b4350c7f0 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -87,6 +87,7 @@ pub(crate) fn initialize_state( erofs_id: &String, state_path: &Utf8PathBuf, initialize_var: bool, + allow_missing_fsverity: bool, ) -> Result<()> { let sysroot_fd = open( sysroot_path.as_std_path(), @@ -95,7 +96,11 @@ pub(crate) fn initialize_state( ) .context("Opening sysroot")?; - let composefs_fd = bootc_initramfs_setup::mount_composefs_image(&sysroot_fd, &erofs_id, false)?; + let composefs_fd = bootc_initramfs_setup::mount_composefs_image( + &sysroot_fd, + &erofs_id, + allow_missing_fsverity, + )?; let tempdir = TempMount::mount_fd(composefs_fd)?; @@ -234,6 +239,7 @@ pub(crate) async fn write_composefs_state( boot_type: BootType, boot_digest: String, container_details: &ImgConfigManifest, + allow_missing_fsverity: bool, ) -> Result<()> { let state_path = root_path .join(STATE_DIR_RELATIVE) @@ -256,6 +262,7 @@ pub(crate) async fn write_composefs_state( &deployment_id.to_hex(), &state_path, staged.is_none(), + allow_missing_fsverity, )?; let ImageReference { diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 1e9444435..a6fe2ada9 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -55,8 +55,7 @@ pub(crate) struct ImgConfigManifest { /// A parsed composefs command line #[derive(Clone)] pub(crate) struct ComposefsCmdline { - #[allow(dead_code)] - pub insecure: bool, + pub allow_missing_fsverity: bool, pub digest: Box, } @@ -69,12 +68,12 @@ struct DeploymentBootInfo<'a> { impl ComposefsCmdline { pub(crate) fn new(s: &str) -> Self { - let (insecure, digest_str) = s + let (allow_missing_fsverity, digest_str) = s .strip_prefix('?') .map(|v| (true, v)) .unwrap_or_else(|| (false, s)); ComposefsCmdline { - insecure, + allow_missing_fsverity, digest: digest_str.into(), } } @@ -82,8 +81,12 @@ impl ComposefsCmdline { impl std::fmt::Display for ComposefsCmdline { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let insecure = if self.insecure { "?" } else { "" }; - write!(f, "{}={}{}", COMPOSEFS_CMDLINE, insecure, self.digest) + let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" }; + write!( + f, + "{}={}{}", + COMPOSEFS_CMDLINE, allow_missing_fsverity, self.digest + ) } } @@ -809,10 +812,10 @@ mod tests { fn test_composefs_parsing() { const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; let v = ComposefsCmdline::new(DIGEST); - assert!(!v.insecure); + assert!(!v.allow_missing_fsverity); assert_eq!(v.digest.as_ref(), DIGEST); let v = ComposefsCmdline::new(&format!("?{}", DIGEST)); - assert!(v.insecure); + assert!(v.allow_missing_fsverity); assert_eq!(v.digest.as_ref(), DIGEST); } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index a4558961e..1ad1903fc 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -251,7 +251,12 @@ pub(crate) async fn do_upgrade( ) -> Result<()> { start_finalize_stated_svc()?; - let (repo, entries, id, fs) = pull_composefs_repo(&imgref.transport, &imgref.image).await?; + let (repo, entries, id, fs) = pull_composefs_repo( + &imgref.transport, + &imgref.image, + booted_cfs.cmdline.allow_missing_fsverity, + ) + .await?; let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); @@ -267,7 +272,7 @@ pub(crate) async fn do_upgrade( let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), repo, &id, entry, @@ -275,7 +280,7 @@ pub(crate) async fn do_upgrade( )?, BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), repo, &id, entries, @@ -293,6 +298,7 @@ pub(crate) async fn do_upgrade( boot_type, boot_digest, img_manifest_config, + booted_cfs.cmdline.allow_missing_fsverity, ) .await?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 5ff4233b6..d6b00e125 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -411,6 +411,10 @@ pub(crate) enum ContainerOpts { #[clap(long = "karg", hide = true)] kargs: Vec, + /// Make fs-verity validation optional in case the filesystem doesn't support it + #[clap(long)] + allow_missing_verity: bool, + /// Additional arguments to pass to ukify (after `--`). #[clap(last = true)] args: Vec, @@ -1624,8 +1628,9 @@ async fn run_from_opt(opt: Opt) -> Result<()> { ContainerOpts::Ukify { rootfs, kargs, + allow_missing_verity, args, - } => crate::ukify::build_ukify(&rootfs, &kargs, &args), + } => crate::ukify::build_ukify(&rootfs, &kargs, &args, allow_missing_verity), }, Opt::Completion { shell } => { use clap_complete::aot::generate; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 4994deffc..1235e10c1 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -193,6 +193,7 @@ use crate::containerenv::ContainerExecutionInfo; use crate::deploy::{ MergeState, PreparedImportMeta, PreparedPullResult, prepare_for_pull, pull_from_prepared, }; +use crate::install::config::Filesystem as FilesystemEnum; use crate::lsm; use crate::progress_jsonl::ProgressWriter; use crate::spec::{Bootloader, ImageReference}; @@ -390,7 +391,7 @@ pub(crate) struct InstallComposefsOpts { /// Make fs-verity validation optional in case the filesystem doesn't support it #[clap(long, default_value_t, requires = "composefs_backend")] #[serde(default)] - pub(crate) insecure: bool, + pub(crate) allow_missing_verity: bool, /// Name of the UKI addons to install without the ".efi.addon" suffix. /// This option can be provided multiple times if multiple addons are to be installed. @@ -1507,6 +1508,7 @@ async fn prepare_install( source_opts: InstallSourceOpts, target_opts: InstallTargetOpts, mut composefs_options: InstallComposefsOpts, + target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1576,12 +1578,15 @@ async fn prepare_install( }; tracing::debug!("Target image reference: {target_imgref}"); - let composefs_required = if let Some(root) = target_rootfs.as_ref() { - crate::kernel::find_kernel(root)? - .map(|k| k.kernel.unified) - .unwrap_or(false) + let (composefs_required, kernel) = if let Some(root) = target_rootfs.as_ref() { + let kernel = crate::kernel::find_kernel(root)?; + + ( + kernel.as_ref().map(|k| k.kernel.unified).unwrap_or(false), + kernel, + ) } else { - false + (false, None) }; tracing::debug!("Composefs required: {composefs_required}"); @@ -1656,6 +1661,59 @@ async fn prepare_install( tracing::debug!("No install configuration found"); } + let root_filesystem = target_fs + .or(install_config + .as_ref() + .and_then(|c| c.filesystem_root()) + .and_then(|r| r.fstype)) + .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?; + + let mut is_uki = false; + + // For composefs backend, automatically disable fs-verity hard requirement if the + // filesystem doesn't support it + // + // If we have a sealed UKI on our hands, then we can assume that user wanted fs-verity so + // we hard require it in that particular case + // + // NOTE: This isn't really 100% accurate 100% of the time as the cmdline can be in an addon + match kernel { + Some(k) => match k.k_type { + crate::kernel::KernelType::Uki { + allow_missing_fsverity, + .. + } => { + if !allow_missing_fsverity { + anyhow::ensure!( + root_filesystem.supports_fsverity(), + "Specified filesystem {root_filesystem} does not support fs-verity" + ); + } + + composefs_options.allow_missing_verity = allow_missing_fsverity; + is_uki = true; + } + + crate::kernel::KernelType::Vmlinuz { .. } => {} + }, + + None => {} + } + + // If `--allow-missing-verity` is already passed via CLI, don't modify + if composefs_options.composefs_backend && !composefs_options.allow_missing_verity && !is_uki { + composefs_options.allow_missing_verity = !root_filesystem.supports_fsverity(); + + tracing::debug!( + "Missing fsverity {}", + if composefs_options.allow_missing_verity { + "allowed" + } else { + "not allowed" + } + ); + } + if let Some(crate::spec::Bootloader::None) = config_opts.bootloader { if cfg!(target_arch = "s390x") { anyhow::bail!("Bootloader set to none is not supported for the s390x architecture"); @@ -1909,10 +1967,21 @@ async fn install_to_filesystem_impl( if state.composefs_options.composefs_backend { // Load a fd for the mounted target physical root - let (id, verity) = initialize_composefs_repository(state, rootfs).await?; + let (id, verity) = initialize_composefs_repository( + state, + rootfs, + state.composefs_options.allow_missing_verity, + ) + .await?; tracing::info!("id: {id}, verity: {}", verity.to_hex()); - setup_composefs_boot(rootfs, state, &id).await?; + setup_composefs_boot( + rootfs, + state, + &id, + state.composefs_options.allow_missing_verity, + ) + .await?; } else { ostree_install(state, rootfs, cleanup).await?; } @@ -1983,6 +2052,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.source_opts, opts.target_opts, opts.composefs_opts, + block_opts.filesystem, ) .await?; @@ -2283,19 +2353,6 @@ pub(crate) async fn install_to_filesystem( target_path ); - // Gather global state, destructuring the provided options. - // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things) - // 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, - opts.composefs_opts, - ) - .await?; - // And the last bit of state here is the fsopts, which we also destructure now. let mut fsopts = opts.filesystem_opts; @@ -2317,6 +2374,7 @@ pub(crate) async fn install_to_filesystem( } let target_root_path = fsopts.root_path.clone(); + // Get a file descriptor for the root path /target let target_rootfs_fd = Dir::open_ambient_dir(&target_root_path, cap_std::ambient_authority()) @@ -2339,11 +2397,6 @@ pub(crate) async fn install_to_filesystem( } } - // Check to see if this happens to be the real host root - if !fsopts.acknowledge_destructive { - warn_on_host_root(&target_rootfs_fd)?; - } - // If we're installing to an ostree root, then find the physical root from // the deployment root. let possible_physical_root = fsopts.root_path.join("sysroot"); @@ -2373,6 +2426,28 @@ pub(crate) async fn install_to_filesystem( target_rootfs_fd.try_clone()? }; + // Gather data about the root filesystem + let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?; + + // Gather global state, destructuring the provided options. + // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things) + // 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, + opts.composefs_opts, + Some(inspect.fstype.as_str().try_into()?), + ) + .await?; + + // Check to see if this happens to be the real host root + if !fsopts.acknowledge_destructive { + warn_on_host_root(&target_rootfs_fd)?; + } + match fsopts.replace { Some(ReplaceMode::Wipe) => { let rootfs_fd = rootfs_fd.try_clone()?; @@ -2386,9 +2461,6 @@ pub(crate) async fn install_to_filesystem( None => require_empty_rootdir(&rootfs_fd)?, } - // Gather data about the root filesystem - let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?; - // We support overriding the mount specification for root (i.e. LABEL vs UUID versus // raw paths). // We also support an empty specification as a signal to omit any mountspec kargs. diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 2e82533e2..07acd2b5e 100644 --- a/crates/lib/src/install/config.rs +++ b/crates/lib/src/install/config.rs @@ -32,6 +32,25 @@ impl std::fmt::Display for Filesystem { } } +impl TryFrom<&str> for Filesystem { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "xfs" => Ok(Self::Xfs), + "ext4" => Ok(Self::Ext4), + "btrfs" => Ok(Self::Btrfs), + other => anyhow::bail!("Unknown filesystem: {}", other), + } + } +} + +impl Filesystem { + pub(crate) fn supports_fsverity(&self) -> bool { + matches!(self, Self::Ext4 | Self::Btrfs) + } +} + /// The toplevel config entry for installation configs stored /// in bootc/install (e.g. /etc/bootc/install/05-custom.toml) #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs index 6cfd85156..5803845bc 100644 --- a/crates/lib/src/kernel.rs +++ b/crates/lib/src/kernel.rs @@ -6,13 +6,16 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; +use bootc_kernel_cmdline::utf8::Cmdline; use camino::Utf8PathBuf; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use serde::Serialize; use crate::bootc_composefs::boot::EFI_LINUX; +use crate::bootc_composefs::status::ComposefsCmdline; +use crate::composefs_consts::COMPOSEFS_CMDLINE; /// Information about the kernel in a container image. #[derive(Debug, Serialize)] @@ -31,8 +34,11 @@ pub(crate) struct Kernel { /// UKI kernels only have the single PE binary, whereas /// traditional "vmlinuz" kernels have distinct kernel and /// initramfs. -pub(crate) enum KernelPath { - Uki(Utf8PathBuf), +pub(crate) enum KernelType { + Uki { + path: Utf8PathBuf, + allow_missing_fsverity: bool, + }, Vmlinuz { path: Utf8PathBuf, initramfs: Utf8PathBuf, @@ -47,7 +53,7 @@ pub(crate) enum KernelPath { /// to get the "public" form where needed. pub(crate) struct KernelInternal { pub(crate) kernel: Kernel, - pub(crate) path: KernelPath, + pub(crate) k_type: KernelType, } impl From for Kernel { @@ -67,12 +73,48 @@ pub(crate) fn find_kernel(root: &Dir) -> Result> { // First, try to find a UKI if let Some(uki_path) = find_uki_path(root)? { let version = uki_path.file_stem().unwrap_or(uki_path.as_str()).to_owned(); + + let uki = root.read(&uki_path).context("Reading UKI")?; + + // Best effort to check for composefs=?verity in the UKI cmdline + let cmdline = composefs_boot::uki::get_section(&uki, ".cmdline"); + + let allow_missing_fsverity = match cmdline { + Some(Ok(cmdline)) => { + let cmdline_str = std::str::from_utf8(cmdline)?; + + let cmdline = Cmdline::from(cmdline_str); + + match cmdline.find(COMPOSEFS_CMDLINE) { + Some(param) => ComposefsCmdline::new(¶m).allow_missing_fsverity, + + // The cmdline might be in an addon, so don't allow missing verity + None => false, + } + } + + Some(Err(uki_error)) => match uki_error { + composefs_boot::uki::UkiError::MissingSection(_) => { + // TODO(Johan-Liebert1): Check this when we have full UKI Addons support + // The cmdline might be in an addon, so don't allow missing verity + false + } + + e => anyhow::bail!("Failed to read UKI cmdline: {e:?}"), + }, + + None => false, + }; + return Ok(Some(KernelInternal { kernel: Kernel { version, unified: true, }, - path: KernelPath::Uki(uki_path), + k_type: KernelType::Uki { + path: uki_path, + allow_missing_fsverity, + }, })); } @@ -89,7 +131,7 @@ pub(crate) fn find_kernel(root: &Dir) -> Result> { version, unified: false, }, - path: KernelPath::Vmlinuz { + k_type: KernelType::Vmlinuz { path: vmlinuz, initramfs, }, @@ -156,8 +198,8 @@ mod tests { let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel"); assert_eq!(kernel_internal.kernel.version, "6.12.0-100.fc41.x86_64"); assert!(!kernel_internal.kernel.unified); - match &kernel_internal.path { - KernelPath::Vmlinuz { path, initramfs } => { + match &kernel_internal.k_type { + KernelType::Vmlinuz { path, initramfs } => { assert_eq!( path.as_str(), "usr/lib/modules/6.12.0-100.fc41.x86_64/vmlinuz" @@ -167,7 +209,7 @@ mod tests { "usr/lib/modules/6.12.0-100.fc41.x86_64/initramfs.img" ); } - KernelPath::Uki(_) => panic!("Expected Vmlinuz, got Uki"), + KernelType::Uki { .. } => panic!("Expected Vmlinuz, got Uki"), } Ok(()) } @@ -181,11 +223,11 @@ mod tests { let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel"); assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0"); assert!(kernel_internal.kernel.unified); - match &kernel_internal.path { - KernelPath::Uki(path) => { + match &kernel_internal.k_type { + KernelType::Uki { path, .. } => { assert_eq!(path.as_str(), "boot/EFI/Linux/fedora-6.12.0.efi"); } - KernelPath::Vmlinuz { .. } => panic!("Expected Uki, got Vmlinuz"), + KernelType::Vmlinuz { .. } => panic!("Expected Uki, got Vmlinuz"), } Ok(()) } diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 66716b88e..a783053bc 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::fmt::Display; use uapi_version::Version; +use crate::bootc_composefs::status::ComposefsCmdline; use crate::composefs_consts::COMPOSEFS_CMDLINE; #[derive(Debug, PartialEq, Eq, Default)] @@ -189,15 +190,16 @@ impl BLSConfig { let kv = cmdline .find(COMPOSEFS_CMDLINE) - .ok_or(anyhow::anyhow!("No composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?; let value = kv .value() - .ok_or(anyhow::anyhow!("Empty composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("Empty composefs= param"))?; - let value = value.to_owned(); + let cfs_cmdline = ComposefsCmdline::new(value); - Ok(value) + // TODO(Johan-Liebert1): We lose the info here that this is insecure + Ok(cfs_cmdline.digest.to_string().clone()) } BLSConfigType::Unknown => anyhow::bail!("Unknown config type"), diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 6f3405896..dda245a0d 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -190,7 +190,7 @@ impl BootedStorage { Environment::ComposefsBooted(cmdline) => { let (physical_root, run) = get_physical_root_and_run()?; let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?; - if cmdline.insecure { + if cmdline.allow_missing_fsverity { composefs.set_insecure(true); } let composefs = Arc::new(composefs); diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 05e5d86ed..5602cf5e0 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -30,6 +30,7 @@ pub(crate) fn build_ukify( rootfs: &Utf8Path, extra_kargs: &[String], args: &[OsString], + allow_missing_fsverity: bool, ) -> Result<()> { // Warn if --karg is used (temporary workaround) if !extra_kargs.is_empty() { @@ -55,9 +56,9 @@ pub(crate) fn build_ukify( .ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?; // Extract vmlinuz and initramfs paths, or bail if this is already a UKI - let (vmlinuz_path, initramfs_path) = match kernel.path { - crate::kernel::KernelPath::Vmlinuz { path, initramfs } => (path, initramfs), - crate::kernel::KernelPath::Uki(path) => { + let (vmlinuz_path, initramfs_path) = match kernel.k_type { + crate::kernel::KernelType::Vmlinuz { path, initramfs } => (path, initramfs), + crate::kernel::KernelType::Uki { path, .. } => { anyhow::bail!("Cannot build UKI: rootfs already contains a UKI at {path}"); } }; @@ -83,7 +84,11 @@ pub(crate) fn build_ukify( let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; // Add the composefs digest - let composefs_param = format!("{COMPOSEFS_CMDLINE}={composefs_digest}"); + let composefs_param = if allow_missing_fsverity { + format!("{COMPOSEFS_CMDLINE}=?{composefs_digest}") + } else { + format!("{COMPOSEFS_CMDLINE}={composefs_digest}") + }; cmdline.extend(&Cmdline::from(composefs_param)); // Add any extra kargs provided via --karg @@ -129,7 +134,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[]); + let result = build_ukify(path, &[], &[], false); assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -147,7 +152,7 @@ mod tests { fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); fs::write(tempdir.path().join("boot/EFI/Linux/test.efi"), b"fake uki").unwrap(); - let result = build_ukify(path, &[], &[]); + let result = build_ukify(path, &[], &[], false); assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 5ef546321..1f14ddcf4 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -485,9 +485,8 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { } if args.composefs_backend { - // TODO(Johan-Liebert1): Filesystem should be a parameter and we should test - // insecure with xfs - opts.push("--filesystem=ext4".into()); + let filesystem = args.filesystem.as_deref().unwrap_or("ext4"); + opts.push(format!("--filesystem={}", filesystem)); opts.push("--composefs-backend".into()); opts.extend(COMPOSEFS_KERNEL_ARGS.map(|x| x.into())); } diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index cb6afe29f..056bd780e 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -127,6 +127,9 @@ pub(crate) struct RunTmtArgs { #[arg(long, requires = "composefs_backend")] pub(crate) bootloader: Option, + + #[arg(long, requires = "composefs_backend")] + pub(crate) filesystem: Option, } /// Arguments for tmt-provision command diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 1c8af4881..3aa0f9907 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -181,4 +181,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-34-user-agent + +/plan-36-rollback: + summary: Test bootc rollback functionality through image switch and rollback cycle + discover: + how: fmf + test: + - /tmt/tests/tests/test-36-rollback # END GENERATED PLANS diff --git a/tmt/tests/booted/test-rollback.nu b/tmt/tests/booted/test-rollback.nu new file mode 100644 index 000000000..0f2e2ee89 --- /dev/null +++ b/tmt/tests/booted/test-rollback.nu @@ -0,0 +1,117 @@ +# number: 36 +# tmt: +# summary: Test bootc rollback functionality +# duration: 30m +# +# This test verifies bootc rollback functionality: +# 1. Captures the initial deployment state +# 2. Switches to a different image +# 3. Verifies the switch was successful +# 4. Performs bootc rollback +# 5. Reboots and verifies we're back to the original deployment + +use std assert +use tap.nu +use bootc_testlib.nu + +bootc status +journalctl --list-boots + +let st = bootc status --json | from json +let booted = $st.status.booted.image + +def imgsrc [] { + $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" +} + +# Run on the first boot - capture initial state and switch to new image +def initial_switch [] { + tap begin "bootc rollback test" + + print "=== Initial boot - capturing state and switching image ===" + + # Store initial deployment information for later verification + let initial_st = bootc status --json | from json + let initial_image = $initial_st.status.booted.image + + $initial_image | to json | save /var/bootc-initial-state.json + + let imgsrc = imgsrc + + if ($imgsrc | str ends-with "-local") { + bootc image copy-to-storage + + print "Building derived container" + "FROM localhost/bootc +RUN echo 'This is the rollback target image' > /usr/share/bootc-rollback-marker +" | save Dockerfile + + podman build -t $imgsrc . + print $"Built derived image: ($imgsrc)" + } + + print $"Switching to ($imgsrc)" + bootc switch --transport containers-storage $imgsrc + + print "Switch completed, rebooting to new image..." + tmt-reboot +} + +# Check that we successfully switched to the new image and then rollback +def second_boot_rollback [] { + print "=== Second boot - verifying switch and performing rollback ===" + + # Verify we're running the new image + assert equal $booted.image.image $"(imgsrc)" + print "Successfully switched to new image" + + assert ("/usr/share/bootc-rollback-marker" | path exists) + print "New image artifacts verified" + + print "Performing bootc rollback..." + bootc rollback + + print "Rollback initiated, rebooting to previous deployment..." + tmt-reboot +} + +def back_to_first_depl [boot_count] { + print $"=== ($boot_count) boot - verifying rollback success ===" + + # Load the original state we saved and verify we're back to the original image + let original_state = cat /var/bootc-initial-state.json | from json + + assert equal $booted.image $original_state.image + print $"Successfully rolled back to original image: ($booted.image.image)" + + if ("/usr/share/bootc-rollback-marker" | path exists) { + error make { msg: "Rollback target marker still present - rollback may have failed" } + } +} + +# Verify that rollback was successful and we're back to original deployment +def third_boot_verify [] { + back_to_first_depl Third + + # Finally test a double rollback, to make sure the rollback state is queued then unqueued + bootc rollback + bootc rollback + + tmt-reboot +} + +def fourth_boot_verify [] { + back_to_first_depl Fourth + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_switch, + "1" => second_boot_rollback, + "2" => third_boot_verify, + "3" => fourth_boot_verify, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 4d808880e..a1f5980e2 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -101,3 +101,8 @@ summary: Verify bootc sends correct User-Agent header to registries duration: 10m test: python3 booted/test-user-agent.py + +/test-36-rollback: + summary: Test bootc rollback functionality through image switch and rollback cycle + duration: 30m + test: nu booted/test-rollback.nu