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