From 813da0ca9b76e701d073e0a11ae2618e2ce5629b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Mon, 5 Jan 2026 12:46:50 +0530 Subject: [PATCH 1/4] composefs: Implement `bootc image copy-to-storage` Export a composefs repository as an OCI image. In this iteration the outputted files are in OCI Directory format and are plain TARs, i.e. not compressed Signed-off-by: Pragyan Poudyal --- Cargo.lock | 1 + crates/lib/Cargo.toml | 1 + crates/lib/src/bootc_composefs/export.rs | 271 +++++++++++++++++++++++ crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/repo.rs | 2 +- crates/lib/src/bootc_composefs/status.rs | 5 +- crates/lib/src/cli.rs | 13 +- 7 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/export.rs diff --git a/Cargo.lock b/Cargo.lock index 5242d2147..08d3e49d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,6 +265,7 @@ dependencies = [ "serde_yaml", "similar-asserts", "static_assertions", + "tar", "tempfile", "thiserror 2.0.17", "tini", diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index ab03da40d..33c3f1b69 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -65,6 +65,7 @@ nom = "8.0.0" schemars = { version = "1.0.4", features = ["chrono04"] } serde_ignored = "0.1.10" serde_yaml = "0.9.34" +tar = "0.4.43" tini = "1.3.0" uuid = { version = "1.8.0", features = ["v4"] } uapi-version = "0.4.0" diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs new file mode 100644 index 000000000..4575598f5 --- /dev/null +++ b/crates/lib/src/bootc_composefs/export.rs @@ -0,0 +1,271 @@ +#![allow(dead_code, unused_variables)] + +use std::io::{Read, Seek, Write}; + +use anyhow::{Context, Result}; +use canon_json::CanonJsonSerialize; +use cap_std_ext::cap_std::{ + ambient_authority, + fs::{Dir, MetadataExt, OpenOptions}, +}; +use composefs::{ + fsverity::FsVerityHashValue, + splitstream::{SplitStreamData, SplitStreamReader}, + tree::{LeafContent, RegularFile}, +}; +use composefs_oci::tar::TarItem; +use openssl::sha::Sha256; +use ostree_ext::oci_spec::image::{Descriptor, Digest, ImageConfiguration, MediaType}; +use tar::{EntryType, Header}; + +use crate::{ + bootc_composefs::{ + status::{get_composefs_status, get_imginfo}, + update::str_to_sha256digest, + }, + store::{BootedComposefs, Storage}, +}; + +fn get_entry_with_header( + reader: &mut SplitStreamReader, +) -> anyhow::Result)>> { + loop { + let mut buf = [0u8; 512]; + if !reader.read_inline_exact(&mut buf)? || buf == [0u8; 512] { + return Ok(None); + } + + let header = tar::Header::from_byte_slice(&buf); + + let size = header.entry_size()?; + + let item = match reader.read_exact(size as usize, ((size + 511) & !511) as usize)? { + SplitStreamData::External(id) => match header.entry_type() { + EntryType::Regular | EntryType::Continuous => { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(id, size))) + } + _ => anyhow::bail!("Unsupported external-chunked entry {header:?} {id:?}"), + }, + + SplitStreamData::Inline(content) => match header.entry_type() { + EntryType::Directory => TarItem::Directory, + // We do not care what the content is as we're re-archiving it anyway + _ => TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(content))), + }, + }; + + return Ok(Some((header.clone(), item))); + } +} + +pub async fn export_repo_to_oci(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> { + let host = get_composefs_status(storage, booted_cfs).await?; + + let image = host + .status + .booted + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))? + .image + .as_ref() + .unwrap(); + + let imginfo = get_imginfo( + storage, + &booted_cfs.cmdline.digest, + // TODO: Make this optional + &image.image, + ) + .await?; + + let config_name = &image.image_digest; + let config_name = str_to_sha256digest(&config_name)?; + + let var_tmp = + Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?; + + var_tmp + .create_dir_all(&*booted_cfs.cmdline.digest) + .context("Creating image dir")?; + + let image_dir = var_tmp + .open_dir(&*booted_cfs.cmdline.digest) + .context("Opening image dir")?; + + let mut config_stream = booted_cfs + .repo + .open_stream(&hex::encode(config_name), None) + .context("Opening config stream")?; + + let config = ImageConfiguration::from_reader(&mut config_stream)?; + + // We can't guarantee that we'll get the same tar as the container image + let mut new_config = config.clone(); + if let Some(history) = new_config.history_mut() { + history.clear(); + } + new_config.rootfs_mut().diff_ids_mut().clear(); + + let mut new_manifest = imginfo.manifest.clone(); + new_manifest.layers_mut().clear(); + + let mut file_open_opts = OpenOptions::new(); + file_open_opts.write(true).create(true); + + for (idx, diff_id) in config.rootfs().diff_ids().iter().enumerate() { + let layer_sha256 = str_to_sha256digest(diff_id)?; + let layer_verity = config_stream.lookup(&layer_sha256)?; + + let mut layer_stream = booted_cfs + .repo + .open_stream(&hex::encode(layer_sha256), Some(layer_verity))?; + + let mut file = image_dir.open_with(hex::encode(layer_sha256), &file_open_opts)?; + + let mut builder = tar::Builder::new(&mut file); + + while let Some((header, entry)) = get_entry_with_header(&mut layer_stream)? { + let hsize = header.size()? as usize; + let mut v = vec![0; hsize]; + + match &entry { + TarItem::Directory => { + assert_eq!(hsize, 0); + } + + TarItem::Leaf(leaf_content) => { + match &leaf_content { + LeafContent::Regular(reg) => match reg { + RegularFile::Inline(items) => { + assert_eq!(hsize, items.len()); + v[..hsize].copy_from_slice(items); + } + + RegularFile::External(obj_id, size) => { + assert_eq!(*size as usize, hsize); + + let mut file = + std::fs::File::from(booted_cfs.repo.open_object(obj_id)?); + + file.read_exact(&mut v)?; + } + }, + + LeafContent::BlockDevice(_) => todo!(), + LeafContent::CharacterDevice(_) => { + todo!() + } + LeafContent::Fifo => todo!(), + LeafContent::Socket => todo!(), + + LeafContent::Symlink(..) => { + // we don't need to write the data for symlinks as the + // target will be in the header itself + assert_eq!(hsize, 0); + } + } + } + + TarItem::Hardlink(..) => { + // we don't need to write the data for hardlinks as the + // target will be in the header itself + assert_eq!(hsize, 0); + } + }; + + builder + .append(&header, v.as_slice()) + .context("Failed to write entry")?; + } + + builder.finish().context("Finishing builder")?; + drop(builder); + + let mut new_diff_id = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + + file.seek(std::io::SeekFrom::Start(0)) + .context("Seek failed")?; + std::io::copy(&mut file, &mut new_diff_id).context("Failed to compute hash")?; + + let final_sha = new_diff_id.finish()?; + let final_sha_str = hex::encode(final_sha); + + rustix::fs::renameat(&image_dir, hex::encode(layer_sha256), &image_dir, &final_sha_str) + .context("Renameat")?; + + let digest = format!("sha256:{}", hex::encode(final_sha)); + + new_config.rootfs_mut().diff_ids_mut().push(digest.clone()); + + // TODO: Gzip this for manifest + new_manifest.layers_mut().push(Descriptor::new( + MediaType::ImageLayer, + file.metadata()?.size(), + Digest::try_from(digest)?, + )); + + if let Some(old_history) = &config.history() { + if idx >= old_history.len() { + anyhow::bail!("Found more layers than history"); + } + + let old_history = &old_history[idx]; + + let mut history = ostree_ext::oci_spec::image::HistoryBuilder::default(); + + if let Some(old_created) = old_history.created() { + history = history.created(old_created); + } + + if let Some(old_created_by) = old_history.created_by() { + history = history.created_by(old_created_by); + } + + if let Some(comment) = old_history.comment() { + history = history.comment(comment); + } + + new_config + .history_mut() + .get_or_insert(Vec::new()) + .push(history.build().unwrap()); + } + + // TODO: Fsync + } + + let config_json = new_config.to_canon_json_vec()?; + + // Hash the new config + let mut config_hash = Sha256::new(); + config_hash.update(&config_json); + let config_hash = hex::encode(config_hash.finish()); + + // Write the config to Directory + let mut cfg_file = image_dir + .open_with(&config_hash, &file_open_opts) + .context("Opening config file")?; + + cfg_file + .write_all(&config_json) + .context("Failed to write config")?; + + // Write the manifest + let mut manifest_file = image_dir + .open_with("manifest.json", &file_open_opts) + .context("Opening manifest file")?; + + new_manifest.set_config(Descriptor::new( + MediaType::ImageConfig, + config_json.len() as u64, + Digest::try_from(format!("sha256:{config_hash}"))?, + )); + + manifest_file + .write_all(&new_manifest.to_canon_json_vec()?) + .context("Failed to write manifest")?; + + println!("Image: {config_hash}"); + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index ddc88c516..d3dda0f5f 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod boot; pub(crate) mod delete; pub(crate) mod digest; +pub(crate) mod export; pub(crate) mod finalize; pub(crate) mod gc; pub(crate) mod repo; diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index c3f478169..f97f76f44 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -61,7 +61,7 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { let img = image.strip_prefix(":").unwrap_or(&image); let transport = transport.strip_suffix(":").unwrap_or(&transport); - if transport == "registry" { + if transport == "registry" || transport == "docker://" { format!("docker://{img}") } else if transport == "docker-daemon" { format!("docker-daemon:{img}") diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 579c506d9..35d5fdfde 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -198,7 +198,10 @@ pub(crate) async fn get_container_manifest_and_config( 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 img = proxy + .open_image(&imgref) + .await + .with_context(|| format!("Opening image {imgref}"))?; let (_, manifest) = proxy.fetch_manifest(&img).await?; let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index c4705419e..3330c1410 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -43,7 +43,6 @@ use crate::bootc_composefs::{ update::upgrade_composefs, }; use crate::deploy::{MergeState, RequiredHostSpec}; -use crate::lints; use crate::podstorage::set_additional_image_store; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; @@ -51,6 +50,7 @@ use crate::spec::ImageReference; use crate::store::{BootedOstree, Storage}; use crate::store::{BootedStorage, BootedStorageKind}; use crate::utils::sigpolicy_from_opt; +use crate::{bootc_composefs, lints}; /// Shared progress options #[derive(Debug, Parser, PartialEq, Eq)] @@ -1587,7 +1587,16 @@ async fn run_from_opt(opt: Opt) -> Result<()> { list_format, } => crate::image::list_entrypoint(list_type, list_format).await, ImageOpts::CopyToStorage { source, target } => { - crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await + let storage = get_storage().await?; + + match storage.kind()? { + BootedStorageKind::Ostree(..) => { + crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await + } + BootedStorageKind::Composefs(booted) => { + bootc_composefs::export::export_repo_to_oci(&storage, &booted).await + } + } } ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await, ImageOpts::PullFromDefaultStorage { image } => { From 80da5c4bc600a769ce5e64bfe6b6d2009a6b7898 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 6 Jan 2026 14:16:42 +0530 Subject: [PATCH 2/4] composefs/export: Use `ocidir` for creating OCI compliant directory Instead of handling the history,metadata,annotations ourselves, delegate them to `ocidir` crate. Also take into account the source and target image references Finally call `skopeo::copy` to copy to containers-storage Signed-off-by: Pragyan Poudyal --- Cargo.lock | 1 + crates/lib/Cargo.toml | 1 + crates/lib/src/bootc_composefs/export.rs | 333 +++++++++++----------- crates/lib/src/bootc_composefs/status.rs | 6 +- crates/lib/src/bootc_composefs/update.rs | 2 +- crates/lib/src/cli.rs | 9 +- crates/lib/src/image.rs | 2 +- crates/ostree-ext/src/container/mod.rs | 2 +- crates/ostree-ext/src/container/skopeo.rs | 2 +- 9 files changed, 177 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08d3e49d8..ff3fec34a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,7 @@ dependencies = [ "libsystemd", "linkme", "nom", + "ocidir", "openssl", "ostree-ext", "regex", diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 33c3f1b69..fb700ddd6 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -62,6 +62,7 @@ liboverdrop = "0.1.0" libsystemd = "0.7" linkme = "0.3" nom = "8.0.0" +ocidir = "0.6.0" schemars = { version = "1.0.4", features = ["chrono04"] } serde_ignored = "0.1.10" serde_yaml = "0.9.34" diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index 4575598f5..3966646c0 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -1,21 +1,16 @@ -#![allow(dead_code, unused_variables)] - -use std::io::{Read, Seek, Write}; +use std::{fs::File, io::Read, os::fd::AsRawFd}; use anyhow::{Context, Result}; -use canon_json::CanonJsonSerialize; -use cap_std_ext::cap_std::{ - ambient_authority, - fs::{Dir, MetadataExt, OpenOptions}, -}; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; use composefs::{ fsverity::FsVerityHashValue, splitstream::{SplitStreamData, SplitStreamReader}, tree::{LeafContent, RegularFile}, }; use composefs_oci::tar::TarItem; -use openssl::sha::Sha256; -use ostree_ext::oci_spec::image::{Descriptor, Digest, ImageConfiguration, MediaType}; +use ocidir::{oci_spec::image::Platform, OciDir}; +use ostree_ext::container::skopeo; +use ostree_ext::{container::Transport, oci_spec::image::ImageConfiguration}; use tar::{EntryType, Header}; use crate::{ @@ -23,45 +18,50 @@ use crate::{ status::{get_composefs_status, get_imginfo}, update::str_to_sha256digest, }, + image::IMAGE_DEFAULT, store::{BootedComposefs, Storage}, }; fn get_entry_with_header( reader: &mut SplitStreamReader, ) -> anyhow::Result)>> { - loop { - let mut buf = [0u8; 512]; - if !reader.read_inline_exact(&mut buf)? || buf == [0u8; 512] { - return Ok(None); - } + let mut buf = [0u8; 512]; + if !reader.read_inline_exact(&mut buf)? || buf == [0u8; 512] { + return Ok(None); + } - let header = tar::Header::from_byte_slice(&buf); + let header = tar::Header::from_byte_slice(&buf); - let size = header.entry_size()?; + let size = header.entry_size()?; - let item = match reader.read_exact(size as usize, ((size + 511) & !511) as usize)? { - SplitStreamData::External(id) => match header.entry_type() { - EntryType::Regular | EntryType::Continuous => { - TarItem::Leaf(LeafContent::Regular(RegularFile::External(id, size))) - } - _ => anyhow::bail!("Unsupported external-chunked entry {header:?} {id:?}"), - }, + let item = match reader.read_exact(size as usize, ((size + 511) & !511) as usize)? { + SplitStreamData::External(id) => match header.entry_type() { + EntryType::Regular | EntryType::Continuous => { + TarItem::Leaf(LeafContent::Regular(RegularFile::External(id, size))) + } + _ => anyhow::bail!("Unsupported external-chunked entry {header:?} {id:?}"), + }, - SplitStreamData::Inline(content) => match header.entry_type() { - EntryType::Directory => TarItem::Directory, - // We do not care what the content is as we're re-archiving it anyway - _ => TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(content))), - }, - }; + SplitStreamData::Inline(content) => match header.entry_type() { + EntryType::Directory => TarItem::Directory, + // We do not care what the content is as we're re-archiving it anyway + _ => TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(content))), + }, + }; - return Ok(Some((header.clone(), item))); - } + return Ok(Some((header.clone(), item))); } -pub async fn export_repo_to_oci(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> { +/// Exports a composefs repository to a container image in containers-storage: +pub async fn export_repo_to_image( + storage: &Storage, + booted_cfs: &BootedComposefs, + source: Option<&str>, + target: Option<&str>, +) -> Result<()> { let host = get_composefs_status(storage, booted_cfs).await?; - let image = host + let booted_image = host .status .booted .as_ref() @@ -70,27 +70,61 @@ pub async fn export_repo_to_oci(storage: &Storage, booted_cfs: &BootedComposefs) .as_ref() .unwrap(); - let imginfo = get_imginfo( - storage, - &booted_cfs.cmdline.digest, - // TODO: Make this optional - &image.image, - ) - .await?; + // If the target isn't specified, push to containers-storage + our default image + let dest_imgref = match target { + Some(target) => ostree_ext::container::ImageReference { + transport: Transport::ContainerStorage, + name: target.to_owned(), + }, + None => ostree_ext::container::ImageReference { + transport: Transport::ContainerStorage, + name: IMAGE_DEFAULT.into(), + }, + }; + + // If the source isn't specified, we use the booted image + let source = match source { + Some(source) => ostree_ext::container::ImageReference::try_from(source) + .context("Parsing source image")?, + + None => ostree_ext::container::ImageReference { + transport: Transport::try_from(booted_image.image.transport.as_str()).unwrap(), + name: booted_image.image.image.clone(), + }, + }; + + let mut depl_verity = None; + + for depl in host + .status + .booted + .iter() + .chain(host.status.staged.iter()) + .chain(host.status.rollback.iter()) + .chain(host.status.other_deployments.iter()) + { + let img = &depl.image.as_ref().unwrap().image; + + // Not checking transport here as we'll be pulling from the repo anyway + // So, image name is all we need + if img.image == source.name { + depl_verity = Some(depl.require_composefs()?.verity.clone()); + break; + } + } - let config_name = &image.image_digest; - let config_name = str_to_sha256digest(&config_name)?; + let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?; + + let imginfo = get_imginfo(storage, &depl_verity, None).await?; + + let config_name = &imginfo.manifest.config().digest().digest(); + let config_name = str_to_sha256digest(config_name)?; let var_tmp = Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?; - var_tmp - .create_dir_all(&*booted_cfs.cmdline.digest) - .context("Creating image dir")?; - - let image_dir = var_tmp - .open_dir(&*booted_cfs.cmdline.digest) - .context("Opening image dir")?; + let tmpdir = cap_std_ext::cap_tempfile::tempdir_in(&var_tmp)?; + let oci_dir = OciDir::ensure(tmpdir.try_clone()?).context("Opening OCI")?; let mut config_stream = booted_cfs .repo @@ -99,7 +133,8 @@ pub async fn export_repo_to_oci(storage: &Storage, booted_cfs: &BootedComposefs) let config = ImageConfiguration::from_reader(&mut config_stream)?; - // We can't guarantee that we'll get the same tar as the container image + // We can't guarantee that we'll get the same tar stream as the container image + // So we create new config and manifest let mut new_config = config.clone(); if let Some(history) = new_config.history_mut() { history.clear(); @@ -109,163 +144,113 @@ pub async fn export_repo_to_oci(storage: &Storage, booted_cfs: &BootedComposefs) let mut new_manifest = imginfo.manifest.clone(); new_manifest.layers_mut().clear(); - let mut file_open_opts = OpenOptions::new(); - file_open_opts.write(true).create(true); - - for (idx, diff_id) in config.rootfs().diff_ids().iter().enumerate() { - let layer_sha256 = str_to_sha256digest(diff_id)?; + for (idx, old_diff_id) in config.rootfs().diff_ids().iter().enumerate() { + let layer_sha256 = str_to_sha256digest(old_diff_id)?; let layer_verity = config_stream.lookup(&layer_sha256)?; let mut layer_stream = booted_cfs .repo .open_stream(&hex::encode(layer_sha256), Some(layer_verity))?; - let mut file = image_dir.open_with(hex::encode(layer_sha256), &file_open_opts)?; - - let mut builder = tar::Builder::new(&mut file); + let mut layer_writer = oci_dir.create_layer(None)?; + layer_writer.follow_symlinks(false); while let Some((header, entry)) = get_entry_with_header(&mut layer_stream)? { let hsize = header.size()? as usize; let mut v = vec![0; hsize]; match &entry { - TarItem::Directory => { - assert_eq!(hsize, 0); - } - TarItem::Leaf(leaf_content) => { match &leaf_content { LeafContent::Regular(reg) => match reg { RegularFile::Inline(items) => { - assert_eq!(hsize, items.len()); v[..hsize].copy_from_slice(items); } - RegularFile::External(obj_id, size) => { - assert_eq!(*size as usize, hsize); - - let mut file = - std::fs::File::from(booted_cfs.repo.open_object(obj_id)?); - + RegularFile::External(obj_id, ..) => { + let mut file = File::from(booted_cfs.repo.open_object(obj_id)?); file.read_exact(&mut v)?; } }, - LeafContent::BlockDevice(_) => todo!(), - LeafContent::CharacterDevice(_) => { - todo!() - } - LeafContent::Fifo => todo!(), - LeafContent::Socket => todo!(), - - LeafContent::Symlink(..) => { - // we don't need to write the data for symlinks as the - // target will be in the header itself - assert_eq!(hsize, 0); - } + // we don't need to write the data for symlinks. + // Same goes for devices, fifos and sockets + _ => {} } } - TarItem::Hardlink(..) => { - // we don't need to write the data for hardlinks as the - // target will be in the header itself - assert_eq!(hsize, 0); - } + // we don't need to write the data for hardlinks/dirs + TarItem::Directory | TarItem::Hardlink(..) => {} }; - builder + layer_writer .append(&header, v.as_slice()) .context("Failed to write entry")?; } - builder.finish().context("Finishing builder")?; - drop(builder); - - let mut new_diff_id = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; - - file.seek(std::io::SeekFrom::Start(0)) - .context("Seek failed")?; - std::io::copy(&mut file, &mut new_diff_id).context("Failed to compute hash")?; - - let final_sha = new_diff_id.finish()?; - let final_sha_str = hex::encode(final_sha); - - rustix::fs::renameat(&image_dir, hex::encode(layer_sha256), &image_dir, &final_sha_str) - .context("Renameat")?; - - let digest = format!("sha256:{}", hex::encode(final_sha)); - - new_config.rootfs_mut().diff_ids_mut().push(digest.clone()); - - // TODO: Gzip this for manifest - new_manifest.layers_mut().push(Descriptor::new( - MediaType::ImageLayer, - file.metadata()?.size(), - Digest::try_from(digest)?, - )); - - if let Some(old_history) = &config.history() { - if idx >= old_history.len() { - anyhow::bail!("Found more layers than history"); - } - - let old_history = &old_history[idx]; - - let mut history = ostree_ext::oci_spec::image::HistoryBuilder::default(); - - if let Some(old_created) = old_history.created() { - history = history.created(old_created); - } - - if let Some(old_created_by) = old_history.created_by() { - history = history.created_by(old_created_by); - } - - if let Some(comment) = old_history.comment() { - history = history.comment(comment); - } - - new_config - .history_mut() - .get_or_insert(Vec::new()) - .push(history.build().unwrap()); - } - - // TODO: Fsync + layer_writer.finish()?; + + let layer = layer_writer + .into_inner() + .context("Getting inner layer writer")? + .complete() + .context("Writing layer to disk")?; + + tracing::debug!("Wrote layer: {}", layer.uncompressed_sha256_as_digest()); + + let previous_annotations = imginfo + .manifest + .layers() + .get(idx) + .and_then(|l| l.annotations().as_ref()) + .cloned(); + + let history = imginfo.config.history().as_ref(); + let history_entry = history.and_then(|v| v.get(idx)); + let previous_description = history_entry + .clone() + .and_then(|h| h.comment().as_deref()) + .unwrap_or_default(); + + let previous_created = history_entry + .and_then(|h| h.created().as_deref()) + .and_then(bootc_utils::try_deserialize_timestamp) + .unwrap_or_default(); + + oci_dir.push_layer_full( + &mut new_manifest, + &mut new_config, + layer, + previous_annotations, + previous_description, + previous_created, + ); } - let config_json = new_config.to_canon_json_vec()?; - - // Hash the new config - let mut config_hash = Sha256::new(); - config_hash.update(&config_json); - let config_hash = hex::encode(config_hash.finish()); - - // Write the config to Directory - let mut cfg_file = image_dir - .open_with(&config_hash, &file_open_opts) - .context("Opening config file")?; - - cfg_file - .write_all(&config_json) - .context("Failed to write config")?; - - // Write the manifest - let mut manifest_file = image_dir - .open_with("manifest.json", &file_open_opts) - .context("Opening manifest file")?; - - new_manifest.set_config(Descriptor::new( - MediaType::ImageConfig, - config_json.len() as u64, - Digest::try_from(format!("sha256:{config_hash}"))?, - )); - - manifest_file - .write_all(&new_manifest.to_canon_json_vec()?) - .context("Failed to write manifest")?; - - println!("Image: {config_hash}"); + let descriptor = oci_dir.write_config(new_config).context("Writing config")?; + + new_manifest.set_config(descriptor); + oci_dir + .insert_manifest(new_manifest, None, Platform::default()) + .context("Writing manifest")?; + + // Pass the temporary oci directory as the current working directory for the skopeo process + let tempoci = ostree_ext::container::ImageReference { + transport: Transport::OciDir, + name: format!("/proc/self/fd/{}", tmpdir.as_raw_fd()), + }; + + skopeo::copy( + &tempoci, + &dest_imgref, + None, + Some(( + std::sync::Arc::new(tmpdir.try_clone()?.into()), + tmpdir.as_raw_fd(), + )), + true, + ) + .await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 35d5fdfde..97c53c12e 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -241,7 +241,7 @@ pub(crate) fn get_bootloader() -> Result { pub(crate) async fn get_imginfo( storage: &Storage, deployment_id: &str, - imgref: &ImageReference, + imgref: Option<&ImageReference>, ) -> Result { let imginfo_fname = format!("{deployment_id}.imginfo"); @@ -254,6 +254,8 @@ pub(crate) async fn get_imginfo( .context("Failed to open file")?; let Some(img_conf) = &mut img_conf else { + let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No imgref or imginfo file found"))?; + let container_details = get_container_manifest_and_config(&get_imgref(&imgref.transport, &imgref.image)) .await?; @@ -294,7 +296,7 @@ async fn boot_entry_from_composefs_deployment( let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; let img_ref = ImageReference::from(ostree_img_ref); - let img_conf = get_imginfo(storage, &verity, &img_ref).await?; + let img_conf = get_imginfo(storage, &verity, Some(&img_ref)).await?; let image_digest = img_conf.manifest.config().digest().to_string(); let architecture = img_conf.config.architecture().to_string(); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 84afdb416..d910e7350 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -411,7 +411,7 @@ pub(crate) async fn upgrade_composefs( if opts.check { let current_manifest = - get_imginfo(storage, &*composefs.cmdline.digest, booted_imgref).await?; + get_imginfo(storage, &*composefs.cmdline.digest, Some(booted_imgref)).await?; let diff = ManifestDiff::new(¤t_manifest.manifest, &img_config.manifest); diff.print(); return Ok(()); diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 3330c1410..2bf2653bc 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1586,6 +1586,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { list_type, list_format, } => crate::image::list_entrypoint(list_type, list_format).await, + ImageOpts::CopyToStorage { source, target } => { let storage = get_storage().await?; @@ -1594,7 +1595,13 @@ async fn run_from_opt(opt: Opt) -> Result<()> { crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await } BootedStorageKind::Composefs(booted) => { - bootc_composefs::export::export_repo_to_oci(&storage, &booted).await + bootc_composefs::export::export_repo_to_image( + &storage, + &booted, + source.as_deref(), + target.as_deref(), + ) + .await } } } diff --git a/crates/lib/src/image.rs b/crates/lib/src/image.rs index b2e3882cf..2e4be2ac2 100644 --- a/crates/lib/src/image.rs +++ b/crates/lib/src/image.rs @@ -19,7 +19,7 @@ use crate::{ }; /// The name of the image we push to containers-storage if nothing is specified. -const IMAGE_DEFAULT: &str = "localhost/bootc"; +pub(crate) const IMAGE_DEFAULT: &str = "localhost/bootc"; /// Check if an image exists in the default containers-storage (podman storage). /// diff --git a/crates/ostree-ext/src/container/mod.rs b/crates/ostree-ext/src/container/mod.rs index 5c252478a..77d8e1717 100644 --- a/crates/ostree-ext/src/container/mod.rs +++ b/crates/ostree-ext/src/container/mod.rs @@ -483,7 +483,7 @@ mod encapsulate; pub use encapsulate::*; mod unencapsulate; pub use unencapsulate::*; -mod skopeo; +pub mod skopeo; pub mod store; mod update_detachedmeta; pub use update_detachedmeta::*; diff --git a/crates/ostree-ext/src/container/skopeo.rs b/crates/ostree-ext/src/container/skopeo.rs index 7ac462509..eceaf0935 100644 --- a/crates/ostree-ext/src/container/skopeo.rs +++ b/crates/ostree-ext/src/container/skopeo.rs @@ -64,7 +64,7 @@ pub(crate) fn spawn(mut cmd: Command) -> Result { /// Use skopeo to copy a container image. #[context("Skopeo copy")] -pub(crate) async fn copy( +pub async fn copy( src: &ImageReference, dest: &ImageReference, authfile: Option<&Path>, From 4b7c12da7078d32ebb97f8f0a92ef0ebb777ad14 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 7 Jan 2026 14:30:08 +0530 Subject: [PATCH 3/4] copy-to-storage: Refactor tar parsing Inline the tar parsing/unpacking Check for two NULL 512 blocks instead of just one Share source image and target image generating code between composefs and ostree Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/export.rs | 162 +++++++++-------------- crates/lib/src/cli.rs | 7 +- crates/lib/src/image.rs | 76 +++++++---- crates/lib/src/status.rs | 2 +- 4 files changed, 120 insertions(+), 127 deletions(-) diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index 3966646c0..e980bbbbe 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -1,57 +1,22 @@ -use std::{fs::File, io::Read, os::fd::AsRawFd}; +use std::{fs::File, os::fd::AsRawFd}; use anyhow::{Context, Result}; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; -use composefs::{ - fsverity::FsVerityHashValue, - splitstream::{SplitStreamData, SplitStreamReader}, - tree::{LeafContent, RegularFile}, -}; -use composefs_oci::tar::TarItem; +use composefs::splitstream::SplitStreamData; use ocidir::{oci_spec::image::Platform, OciDir}; use ostree_ext::container::skopeo; use ostree_ext::{container::Transport, oci_spec::image::ImageConfiguration}; -use tar::{EntryType, Header}; +use tar::EntryType; +use crate::image::get_imgrefs_for_copy; use crate::{ bootc_composefs::{ status::{get_composefs_status, get_imginfo}, update::str_to_sha256digest, }, - image::IMAGE_DEFAULT, store::{BootedComposefs, Storage}, }; -fn get_entry_with_header( - reader: &mut SplitStreamReader, -) -> anyhow::Result)>> { - let mut buf = [0u8; 512]; - if !reader.read_inline_exact(&mut buf)? || buf == [0u8; 512] { - return Ok(None); - } - - let header = tar::Header::from_byte_slice(&buf); - - let size = header.entry_size()?; - - let item = match reader.read_exact(size as usize, ((size + 511) & !511) as usize)? { - SplitStreamData::External(id) => match header.entry_type() { - EntryType::Regular | EntryType::Continuous => { - TarItem::Leaf(LeafContent::Regular(RegularFile::External(id, size))) - } - _ => anyhow::bail!("Unsupported external-chunked entry {header:?} {id:?}"), - }, - - SplitStreamData::Inline(content) => match header.entry_type() { - EntryType::Directory => TarItem::Directory, - // We do not care what the content is as we're re-archiving it anyway - _ => TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(content))), - }, - }; - - return Ok(Some((header.clone(), item))); -} - /// Exports a composefs repository to a container image in containers-storage: pub async fn export_repo_to_image( storage: &Storage, @@ -61,37 +26,7 @@ pub async fn export_repo_to_image( ) -> Result<()> { let host = get_composefs_status(storage, booted_cfs).await?; - let booted_image = host - .status - .booted - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))? - .image - .as_ref() - .unwrap(); - - // If the target isn't specified, push to containers-storage + our default image - let dest_imgref = match target { - Some(target) => ostree_ext::container::ImageReference { - transport: Transport::ContainerStorage, - name: target.to_owned(), - }, - None => ostree_ext::container::ImageReference { - transport: Transport::ContainerStorage, - name: IMAGE_DEFAULT.into(), - }, - }; - - // If the source isn't specified, we use the booted image - let source = match source { - Some(source) => ostree_ext::container::ImageReference::try_from(source) - .context("Parsing source image")?, - - None => ostree_ext::container::ImageReference { - transport: Transport::try_from(booted_image.image.transport.as_str()).unwrap(), - name: booted_image.image.image.clone(), - }, - }; + let (source, dest_imgref) = get_imgrefs_for_copy(source, target).await?; let mut depl_verity = None; @@ -144,6 +79,8 @@ pub async fn export_repo_to_image( let mut new_manifest = imginfo.manifest.clone(); new_manifest.layers_mut().clear(); + let total_layers = config.rootfs().diff_ids().len(); + for (idx, old_diff_id) in config.rootfs().diff_ids().iter().enumerate() { let layer_sha256 = str_to_sha256digest(old_diff_id)?; let layer_verity = config_stream.lookup(&layer_sha256)?; @@ -155,37 +92,60 @@ pub async fn export_repo_to_image( let mut layer_writer = oci_dir.create_layer(None)?; layer_writer.follow_symlinks(false); - while let Some((header, entry)) = get_entry_with_header(&mut layer_stream)? { - let hsize = header.size()? as usize; - let mut v = vec![0; hsize]; - - match &entry { - TarItem::Leaf(leaf_content) => { - match &leaf_content { - LeafContent::Regular(reg) => match reg { - RegularFile::Inline(items) => { - v[..hsize].copy_from_slice(items); - } - - RegularFile::External(obj_id, ..) => { - let mut file = File::from(booted_cfs.repo.open_object(obj_id)?); - file.read_exact(&mut v)?; - } - }, - - // we don't need to write the data for symlinks. - // Same goes for devices, fifos and sockets - _ => {} + let mut got_zero_block = false; + + loop { + let mut buf = [0u8; 512]; + + if !layer_stream + .read_inline_exact(&mut buf) + .context("Reading into buffer")? + { + break; + } + + let all_zeroes = buf.iter().all(|x| *x == 0); + + // EOF for tar + if all_zeroes && got_zero_block { + break; + } else if all_zeroes { + got_zero_block = true; + continue; + } + + got_zero_block = false; + + let header = tar::Header::from_byte_slice(&buf); + + let size = header.entry_size()?; + + match layer_stream.read_exact(size as usize, ((size + 511) & !511) as usize)? { + SplitStreamData::External(obj_id) => match header.entry_type() { + EntryType::Regular | EntryType::Continuous => { + let file = File::from(booted_cfs.repo.open_object(&obj_id)?); + + layer_writer + .append(&header, file) + .context("Failed to write external entry")?; } - } - // we don't need to write the data for hardlinks/dirs - TarItem::Directory | TarItem::Hardlink(..) => {} - }; + _ => anyhow::bail!("Unsupported external-chunked entry {header:?} {obj_id:?}"), + }, + + SplitStreamData::Inline(content) => match header.entry_type() { + EntryType::Directory => { + layer_writer.append(&header, std::io::empty())?; + } - layer_writer - .append(&header, v.as_slice()) - .context("Failed to write entry")?; + // We do not care what the content is as we're re-archiving it anyway + _ => { + layer_writer + .append(&header, &*content) + .context("Failed to write inline entry")?; + } + }, + }; } layer_writer.finish()?; @@ -196,7 +156,11 @@ pub async fn export_repo_to_image( .complete() .context("Writing layer to disk")?; - tracing::debug!("Wrote layer: {}", layer.uncompressed_sha256_as_digest()); + tracing::debug!( + "Wrote layer: {layer_sha} #{layer_num}/{total_layers}", + layer_sha = layer.uncompressed_sha256_as_digest(), + layer_num = idx + 1, + ); let previous_annotations = imginfo .manifest diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 2bf2653bc..b0f1833fa 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1592,7 +1592,12 @@ async fn run_from_opt(opt: Opt) -> Result<()> { match storage.kind()? { BootedStorageKind::Ostree(..) => { - crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await + crate::image::push_entrypoint( + &storage, + source.as_deref(), + target.as_deref(), + ) + .await } BootedStorageKind::Composefs(booted) => { bootc_composefs::export::export_repo_to_image( diff --git a/crates/lib/src/image.rs b/crates/lib/src/image.rs index 2e4be2ac2..b98d72024 100644 --- a/crates/lib/src/image.rs +++ b/crates/lib/src/image.rs @@ -15,6 +15,8 @@ use crate::{ boundimage::query_bound_images, cli::{ImageListFormat, ImageListType}, podstorage::CStorage, + status::get_host, + store::Storage, utils::async_task_with_spinner, }; @@ -139,43 +141,65 @@ pub(crate) async fn list_entrypoint( Ok(()) } -/// Implementation of `bootc image push-to-storage`. -#[context("Pushing image")] -pub(crate) async fn push_entrypoint(source: Option<&str>, target: Option<&str>) -> Result<()> { +/// Returns the source and target ImageReference +/// If the source isn't specified, we use booted image +/// If the target isn't specified, we push to containers-storage with our default image +pub(crate) async fn get_imgrefs_for_copy( + source: Option<&str>, + target: Option<&str>, +) -> Result<(ImageReference, ImageReference)> { // Initialize floating c_storage early - needed for container operations crate::podstorage::ensure_floating_c_storage_initialized(); - let transport = Transport::ContainerStorage; - let sysroot = crate::cli::get_storage().await?; - let ostree = sysroot.get_ostree()?; - let repo = &ostree.repo(); - // If the target isn't specified, push to containers-storage + our default image - let target = if let Some(target) = target { - ImageReference { - transport, + let dest_imgref = match target { + Some(target) => ostree_ext::container::ImageReference { + transport: Transport::ContainerStorage, name: target.to_owned(), - } - } else { - ImageReference { + }, + None => ostree_ext::container::ImageReference { transport: Transport::ContainerStorage, - name: IMAGE_DEFAULT.to_string(), - } + name: IMAGE_DEFAULT.into(), + }, }; // If the source isn't specified, we use the booted image - let source = if let Some(source) = source { - ImageReference::try_from(source).context("Parsing source image")? - } else { - let status = crate::status::get_status_require_booted(&ostree)?; - // SAFETY: We know it's booted - let booted = status.2.status.booted.unwrap(); - let booted_image = booted.image.unwrap().image; - ImageReference { - transport: Transport::try_from(booted_image.transport.as_str()).unwrap(), - name: booted_image.image, + let src_imgref = match source { + Some(source) => ostree_ext::container::ImageReference::try_from(source) + .context("Parsing source image")?, + + None => { + let host = get_host().await?; + + let booted = host + .status + .booted + .ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))?; + + let booted_image = booted.image.unwrap().image; + + ImageReference { + transport: Transport::try_from(booted_image.transport.as_str()).unwrap(), + name: booted_image.image, + } } }; + + return Ok((src_imgref, dest_imgref)); +} + +/// Implementation of `bootc image push-to-storage`. +#[context("Pushing image")] +pub(crate) async fn push_entrypoint( + storage: &Storage, + source: Option<&str>, + target: Option<&str>, +) -> Result<()> { + let (source, target) = get_imgrefs_for_copy(source, target).await?; + + let ostree = storage.get_ostree()?; + let repo = &ostree.repo(); + let mut opts = ostree_ext::container::store::ExportToOCIOpts::default(); opts.progress_to_stdout = true; println!("Copying local image {source} to {target} ..."); diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index e682c9923..20bf11e6c 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -430,7 +430,7 @@ pub(crate) fn get_status( Ok((deployments, host)) } -async fn get_host() -> Result { +pub(crate) async fn get_host() -> Result { let env = crate::store::Environment::detect()?; if env.needs_mount_namespace() { crate::cli::prepare_for_write()?; From c83639d353bec76b8f9a97d714fd230bb5690237 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 8 Jan 2026 13:30:47 +0530 Subject: [PATCH 4/4] copy-to-storage: Fix deadlock in ostree path The sysroot lock was being taken by `get_host` before it was released by the caller. Move the `get_host` function up the stack of calls Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/export.rs | 2 +- crates/lib/src/cli.rs | 5 +++++ crates/lib/src/image.rs | 13 +++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index e980bbbbe..cef2c3ec9 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -26,7 +26,7 @@ pub async fn export_repo_to_image( ) -> Result<()> { let host = get_composefs_status(storage, booted_cfs).await?; - let (source, dest_imgref) = get_imgrefs_for_copy(source, target).await?; + let (source, dest_imgref) = get_imgrefs_for_copy(&host, source, target).await?; let mut depl_verity = None; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b0f1833fa..bf7cad787 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -47,6 +47,7 @@ use crate::podstorage::set_additional_image_store; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; use crate::spec::ImageReference; +use crate::status::get_host; use crate::store::{BootedOstree, Storage}; use crate::store::{BootedStorage, BootedStorageKind}; use crate::utils::sigpolicy_from_opt; @@ -1588,12 +1589,16 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } => crate::image::list_entrypoint(list_type, list_format).await, ImageOpts::CopyToStorage { source, target } => { + // We get "host" here to avoid deadlock in the ostree path + let host = get_host().await?; + let storage = get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(..) => { crate::image::push_entrypoint( &storage, + &host, source.as_deref(), target.as_deref(), ) diff --git a/crates/lib/src/image.rs b/crates/lib/src/image.rs index b98d72024..4aaff294a 100644 --- a/crates/lib/src/image.rs +++ b/crates/lib/src/image.rs @@ -15,7 +15,7 @@ use crate::{ boundimage::query_bound_images, cli::{ImageListFormat, ImageListType}, podstorage::CStorage, - status::get_host, + spec::Host, store::Storage, utils::async_task_with_spinner, }; @@ -145,6 +145,7 @@ pub(crate) async fn list_entrypoint( /// If the source isn't specified, we use booted image /// If the target isn't specified, we push to containers-storage with our default image pub(crate) async fn get_imgrefs_for_copy( + host: &Host, source: Option<&str>, target: Option<&str>, ) -> Result<(ImageReference, ImageReference)> { @@ -169,18 +170,17 @@ pub(crate) async fn get_imgrefs_for_copy( .context("Parsing source image")?, None => { - let host = get_host().await?; - let booted = host .status .booted + .as_ref() .ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))?; - let booted_image = booted.image.unwrap().image; + let booted_image = &booted.image.as_ref().unwrap().image; ImageReference { transport: Transport::try_from(booted_image.transport.as_str()).unwrap(), - name: booted_image.image, + name: booted_image.image.clone(), } } }; @@ -192,10 +192,11 @@ pub(crate) async fn get_imgrefs_for_copy( #[context("Pushing image")] pub(crate) async fn push_entrypoint( storage: &Storage, + host: &Host, source: Option<&str>, target: Option<&str>, ) -> Result<()> { - let (source, target) = get_imgrefs_for_copy(source, target).await?; + let (source, target) = get_imgrefs_for_copy(host, source, target).await?; let ostree = storage.get_ostree()?; let repo = &ostree.repo();