diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b70eb44ce..fafe5b6b6 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1244,6 +1244,7 @@ async fn upgrade( .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; if staged_deployment.is_finalization_locked() { + crate::boundimage::pull_bound_images(storage, &staged_deployment).await?; ostree.change_finalization(&staged_deployment)?; println!("Staged deployment will now be applied on reboot"); } else { diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 739f54d81..162c57913 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -1007,6 +1007,82 @@ impl MergeState { } } +/// Mount an ostree commit as a composefs filesystem, returning a read-only +/// directory handle. +/// +/// This uses `checkout_composefs` to generate an erofs metadata image from +/// the commit, then mounts it via composefs with the ostree repo objects +/// as the backing data store. The returned `Dir` is a detached mount — +/// it stays alive as long as the fd is open. +/// +/// The erofs image is written into the ostree repo's own `tmp/` directory +/// (which lives on the real root filesystem) because erofs file-backed +/// mounts require a non-stacked backing filesystem. +#[context("Mounting ostree commit {commit}")] +pub(crate) fn mount_ostree_commit(repo: &ostree::Repo, commit: &str) -> Result { + use composefs_ctl::composefs::mount::{MountOptions, composefs_fsmount}; + use std::os::fd::AsRawFd; + + // Write the erofs image into the ostree repo's tmp/ directory so it + // lives on the real (non-stacked) filesystem. + let repo_dir = Dir::reopen_dir(&repo.dfd_borrow())?; + let repo_tmp = repo_dir + .open_dir("tmp") + .context("Opening ostree repo tmp/")?; + let td = cap_std_ext::cap_tempfile::TempDir::new_in(&repo_tmp)?; + let image_name = "image.cfs"; + + // Call checkout_composefs via FFI directly; the high-level ostree + // crate (0.20.x) has a broken feature ladder that omits v2024_7. + #[allow(unsafe_code)] + { + use glib::translate::*; + use std::ffi::CString; + let c_path = CString::new(image_name).unwrap(); + let c_commit = CString::new(commit).context("Invalid commit string")?; + let mut error = std::ptr::null_mut(); + // SAFETY: all pointers are valid; the repo, path, and commit are + // borrowed for the duration of the call. + let ok = unsafe { + ostree::ffi::ostree_repo_checkout_composefs( + repo.to_glib_none().0, + std::ptr::null_mut(), + td.as_raw_fd(), + c_path.as_ptr(), + c_commit.as_ptr(), + std::ptr::null_mut(), + &mut error, + ) + }; + if ok == glib::ffi::GFALSE { + // SAFETY: on failure, error is set by the C function. + return Err(unsafe { glib::Error::from_glib_full(error) }) + .context("checkout_composefs"); + } + } + + // Open the erofs image and the ostree objects directory. + let image_fd = td + .open(image_name) + .context("Opening composefs image")? + .into(); + let objects_fd = repo_dir + .open_dir("objects") + .context("Opening ostree objects dir")?; + + // Mount via composefs: erofs metadata + ostree objects as data store. + let mount_fd = composefs_fsmount( + image_fd, + commit, + objects_fd, + false, // no fsverity requirement for read-only inspection + &MountOptions::default(), + ) + .context("composefs_fsmount")?; + + Dir::reopen_dir(&mount_fd).context("Reopening composefs mount as Dir") +} + /// Stage (queue deployment of) a fetched container image. #[context("Staging")] pub(crate) async fn stage( @@ -1054,9 +1130,9 @@ pub(crate) async fn stage( subtask.completed = true; subtasks.push(subtask.clone()); - subtask.subtask = "deploying".into(); - subtask.id = "deploying".into(); - subtask.description = "Deploying Image".into(); + subtask.subtask = "bound_images".into(); + subtask.id = "bound_images".into(); + subtask.description = "Pulling Bound Images".into(); subtask.completed = false; prog.send(Event::ProgressSteps { task: "staging".into(), @@ -1072,15 +1148,20 @@ pub(crate) async fn stage( .collect(), }) .await; - let origin = origin_from_imageref(spec.image)?; - let deployment = - crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?; + // Pull bound images *before* staging by mounting the ostree commit + // as a composefs filesystem to read the image specs. This way a pull + // failure never results in a staged deployment at all. + let repo = sysroot.get_ostree()?.repo(); + let commit_root = mount_ostree_commit(&repo, &image.ostree_commit)?; + let bound_images = crate::boundimage::query_bound_images(&commit_root)?; + drop(commit_root); + crate::boundimage::pull_images(sysroot, bound_images).await?; subtask.completed = true; subtasks.push(subtask.clone()); - subtask.subtask = "bound_images".into(); - subtask.id = "bound_images".into(); - subtask.description = "Pulling Bound Images".into(); + subtask.subtask = "deploying".into(); + subtask.id = "deploying".into(); + subtask.description = "Deploying Image".into(); subtask.completed = false; prog.send(Event::ProgressSteps { task: "staging".into(), @@ -1096,7 +1177,9 @@ pub(crate) async fn stage( .collect(), }) .await; - crate::boundimage::pull_bound_images(sysroot, &deployment).await?; + let origin = origin_from_imageref(spec.image)?; + let _deployment = + crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?; subtask.completed = true; subtasks.push(subtask.clone()); diff --git a/tmt/tests/booted/test-image-pushpull-upgrade.nu b/tmt/tests/booted/test-image-pushpull-upgrade.nu index 708b868ec..237cb3c3f 100644 --- a/tmt/tests/booted/test-image-pushpull-upgrade.nu +++ b/tmt/tests/booted/test-image-pushpull-upgrade.nu @@ -130,9 +130,12 @@ def sanity_check_switch_progress_json [data] { assert equal $deploy.steps 3 assert equal $deploy.stepsTotal 3 let deploy_tasks = $deploy.subtasks + # Bound images are now pulled before staging (see deploy::stage), so + # the "bound_images" subtask now comes before "deploying" instead of + # after it. assert equal ($deploy_tasks | length) 5 let deploy_names = $deploy_tasks | get subtask - assert equal $deploy_names ["merging", "deploying", "bound_images", "cleanup", "cleanup"] + assert equal $deploy_names ["merging", "bound_images", "deploying", "cleanup", "cleanup"] } # The second boot; verify we're in the derived image