Skip to content

Commit 0b80f6e

Browse files
committed
oci: Add "cfsctl oci copy" subcommand
Add a CLI subcommand to copy an OCI image (and all its layers) from one composefs repository to another using the splitdirfdstream transport. Cross-algorithm copies are supported (e.g. sha256 source to sha512 destination); the --zerocopy flag requires matching algorithms since it hardlinks objects in-place with shared fs-verity digests. The global --repo flag selects the destination repository and --from specifies the source, consistent with how --repo works elsewhere in the CLI. Generated-by: https://github.com/cgwalters/cgwalters#llms Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 4e02553 commit 0b80f6e

7 files changed

Lines changed: 941 additions & 9 deletions

File tree

crates/composefs-ctl/src/lib.rs

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ impl From<ErofsVersion> for composefs::erofs::format::FormatVersion {
273273
/// start with `@`.
274274
#[cfg(feature = "oci")]
275275
#[derive(Debug, Clone)]
276-
pub(crate) enum OciReference {
276+
pub enum OciReference {
277277
/// A content-addressable digest such as `sha256:abcdef…`.
278278
Digest(composefs_oci::OciDigest),
279279
/// A named ref resolved through the repository's ref tree, typically
@@ -387,6 +387,31 @@ enum OciCommand {
387387
#[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
388388
local_fetch: LocalFetchCli,
389389
},
390+
/// Copy an OCI image (and its layers) from another composefs repository
391+
/// into this repository.
392+
///
393+
/// The destination repository is selected by the global `--repo`/`--user`/
394+
/// `--system` flags. The source is `--from`.
395+
///
396+
/// Pass `--zerocopy` to attempt reflink (then hardlink) instead of copying
397+
/// object data. This requires both repositories to be on the same
398+
/// filesystem, to use the same hash algorithm, and the caller to have
399+
/// `CAP_DAC_READ_SEARCH` (i.e. root).
400+
/// Without `--zerocopy`, objects are always copied, which is safe on any
401+
/// filesystem and across repositories using different hash algorithms.
402+
Copy {
403+
/// Image to copy (tag name or `@digest`).
404+
image: OciReference,
405+
/// Path to the source composefs repository.
406+
#[clap(long)]
407+
from: PathBuf,
408+
/// Tag to assign to the image in the destination repository.
409+
#[clap(long)]
410+
name: Option<String>,
411+
/// Use reflink/hardlink zero-copy transfer (requires same filesystem, same hash algorithm, and root).
412+
#[clap(long)]
413+
zerocopy: bool,
414+
},
390415
/// List all tagged OCI images in the repository
391416
#[clap(name = "images")]
392417
ListImages {
@@ -1305,6 +1330,111 @@ where
13051330
Ok(repo)
13061331
}
13071332

1333+
/// Copy an OCI image (and all its layers) from one repository to another using varlink connections.
1334+
#[cfg(feature = "oci")]
1335+
pub async fn copy_image(
1336+
conn_src: &mut zlink::unix::Connection,
1337+
conn_dest: &mut zlink::unix::Connection,
1338+
handle_src: u64,
1339+
handle_dest: u64,
1340+
image: &OciReference,
1341+
name: Option<&str>,
1342+
zerocopy: bool,
1343+
) -> Result<crate::varlink::layer_sync::FinalizeImageReply> {
1344+
use crate::varlink::layer_sync::LayerRef;
1345+
use crate::varlink::oci::OciError;
1346+
use crate::varlink::proxy::{GetLayerParams, OciProxy};
1347+
use anyhow::ensure;
1348+
use zlink::futures_util::StreamExt as _;
1349+
1350+
let image_str = image.to_string();
1351+
let inspect = conn_src
1352+
.inspect(handle_src, &image_str)
1353+
.await
1354+
.context("zlink transport error calling Inspect")?
1355+
.map_err(|e: OciError| anyhow::anyhow!("Inspect failed: {e:?}"))?;
1356+
1357+
ensure!(
1358+
!inspect.manifest.is_empty(),
1359+
"inspect returned empty manifest"
1360+
);
1361+
ensure!(!inspect.config.is_empty(), "inspect returned empty config");
1362+
1363+
// Extract ordered layer identifiers via the shared helper that handles
1364+
// both container images (rootfs.diff_ids) and OCI artifacts (manifest
1365+
// layer digests).
1366+
let diff_ids_ordered = composefs_oci::extract_layer_ids(&inspect.manifest, &inspect.config)
1367+
.context("extracting layer identifiers")?;
1368+
1369+
let mut layer_refs: Vec<LayerRef> = Vec::with_capacity(diff_ids_ordered.len());
1370+
1371+
for diff_id in &diff_ids_ordered {
1372+
let has = conn_dest
1373+
.has_layer(handle_dest, diff_id)
1374+
.await
1375+
.context("zlink transport error calling HasLayer")?
1376+
.map_err(|e: OciError| anyhow::anyhow!("HasLayer failed: {e:?}"))?;
1377+
1378+
let layer_verity = if has.present {
1379+
has.layer_verity
1380+
.context("HasLayer returned present=true but no layer_verity")?
1381+
} else {
1382+
let get_params = GetLayerParams {
1383+
diff_id: Some(diff_id.to_string()),
1384+
storage: None,
1385+
};
1386+
let mut get_stream = std::pin::pin!(
1387+
conn_src
1388+
.get_layer(handle_src, get_params)
1389+
.await
1390+
.context("zlink transport error calling GetLayer")?
1391+
);
1392+
let mut all_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
1393+
let mut get_reply = None;
1394+
while let Some(item) = get_stream.next().await {
1395+
let (result, fds) = item.context("GetLayer stream frame error")?;
1396+
let reply =
1397+
result.map_err(|e: OciError| anyhow::anyhow!("GetLayer failed: {e:?}"))?;
1398+
get_reply = Some(reply);
1399+
all_fds.extend(fds);
1400+
}
1401+
let get_reply = get_reply.context("GetLayer returned empty stream")?;
1402+
let dir_count = get_reply.dir_count as usize;
1403+
1404+
let pipe_and_dirfds_len = 1 + dir_count;
1405+
let lifetime_fds = all_fds.split_off(pipe_and_dirfds_len);
1406+
1407+
let put_reply = conn_dest
1408+
.put_layer(handle_dest, diff_id, zerocopy, all_fds)
1409+
.await
1410+
.context("zlink transport error calling PutLayer")?
1411+
.map_err(|e: OciError| anyhow::anyhow!("PutLayer failed: {e:?}"))?;
1412+
drop(lifetime_fds);
1413+
1414+
put_reply.layer_verity
1415+
};
1416+
1417+
layer_refs.push(LayerRef {
1418+
diff_id: diff_id.clone(),
1419+
layer_verity,
1420+
});
1421+
}
1422+
1423+
let finalize = conn_dest
1424+
.finalize_image(
1425+
handle_dest,
1426+
&inspect.manifest,
1427+
&inspect.config,
1428+
layer_refs,
1429+
name,
1430+
)
1431+
.await
1432+
.context("zlink transport error calling FinalizeImage")?
1433+
.map_err(|e: OciError| anyhow::anyhow!("FinalizeImage failed: {e:?}"))?;
1434+
1435+
Ok(finalize)
1436+
}
1437+
13081438
/// Resolve an [`OciReference`] to an [`OciImage`].
13091439
#[cfg(feature = "oci")]
13101440
pub(crate) fn resolve_oci_image<ObjectID: FsVerityHashValue>(
@@ -1508,6 +1638,8 @@ where
15081638
ObjectID: FsVerityHashValue,
15091639
{
15101640
let repo = Arc::new(repo);
1641+
#[cfg(feature = "oci")]
1642+
let dest_path = resolve_repo_path(&args)?;
15111643
match args.cmd {
15121644
Command::Init { .. } => {
15131645
// Handled in run_app before we get here
@@ -1607,6 +1739,81 @@ where
16071739
println!("Boot image: {}", image_verity.to_hex());
16081740
}
16091741
}
1742+
OciCommand::Copy {
1743+
ref image,
1744+
ref from,
1745+
ref name,
1746+
zerocopy,
1747+
} => {
1748+
use crate::varlink::proxy::RepositoryProxy;
1749+
1750+
let src_hash = resolve_hash_type(from, args.hash, !args.no_upgrade)
1751+
.with_context(|| format!("opening source repository {}", from.display()))?;
1752+
let dest_hash = resolve_hash_type(&dest_path, args.hash, !args.no_upgrade)
1753+
.with_context(|| {
1754+
format!("opening destination repository {}", dest_path.display())
1755+
})?;
1756+
1757+
if zerocopy && src_hash != dest_hash {
1758+
anyhow::bail!(
1759+
"--zerocopy requires matching hash algorithms; \
1760+
source uses {src_hash:?} but destination uses {dest_hash:?}"
1761+
);
1762+
}
1763+
1764+
let from_str = from.to_str().context("source path is not valid UTF-8")?;
1765+
let dest_str = dest_path
1766+
.to_str()
1767+
.context("destination path is not valid UTF-8")?;
1768+
1769+
let service_src = crate::varlink::CfsctlService::new();
1770+
let service_dest = crate::varlink::CfsctlService::new();
1771+
1772+
let (mut conn_src, _srv_src) = crate::varlink::spawn_in_process(service_src)
1773+
.context("spawning source in-process service")?;
1774+
let (mut conn_dest, _srv_dest) = crate::varlink::spawn_in_process(service_dest)
1775+
.context("spawning destination in-process service")?;
1776+
1777+
let handle_src = conn_src
1778+
.open_repository(Some(from_str), None, None)
1779+
.await
1780+
.context("zlink transport error calling OpenRepository on source")?
1781+
.map_err(|e| anyhow::anyhow!("OpenRepository failed on source: {e:?}"))?
1782+
.handle;
1783+
1784+
let handle_dest = conn_dest
1785+
.open_repository(Some(dest_str), None, None)
1786+
.await
1787+
.context("zlink transport error calling OpenRepository on destination")?
1788+
.map_err(|e| anyhow::anyhow!("OpenRepository failed on destination: {e:?}"))?
1789+
.handle;
1790+
1791+
let finalize_reply = copy_image(
1792+
&mut conn_src,
1793+
&mut conn_dest,
1794+
handle_src,
1795+
handle_dest,
1796+
image,
1797+
name.as_deref(),
1798+
zerocopy,
1799+
)
1800+
.await?;
1801+
1802+
let tag_info = if let Some(n) = name {
1803+
format!(", tagged as {n}")
1804+
} else {
1805+
String::new()
1806+
};
1807+
println!(
1808+
"Copied image {image} from {} to destination repo{}",
1809+
from.display(),
1810+
tag_info
1811+
);
1812+
println!("Manifest digest: {}", finalize_reply.manifest_digest);
1813+
println!("Manifest verity: {}", finalize_reply.manifest_verity);
1814+
println!("Config digest: {}", finalize_reply.config_digest);
1815+
println!("Config verity: {}", finalize_reply.config_verity);
1816+
}
16101817
OciCommand::ListImages { json } => {
16111818
let images = composefs_oci::oci_image::list_images(&repo)?;
16121819

crates/composefs-ctl/src/varlink.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,12 @@ pub(crate) struct CfsctlService {
279279
open_opts: OpenOptions,
280280
}
281281

282+
impl Default for CfsctlService {
283+
fn default() -> Self {
284+
Self::new()
285+
}
286+
}
287+
282288
impl CfsctlService {
283289
/// Construct an empty service with the given repository open options.
284290
///
@@ -310,6 +316,11 @@ impl CfsctlService {
310316
Self::with_open_opts(OpenOptions::default())
311317
}
312318

319+
/// Construct a service with default open options.
320+
pub(crate) fn new() -> Self {
321+
Self::with_open_opts(OpenOptions::default())
322+
}
323+
313324
/// Construct an insecure service for in-process tests.
314325
///
315326
/// The `insecure` flag disables fs-verity requirements so that tests can
@@ -2827,7 +2838,7 @@ pub(crate) use oci::*;
28272838
/// current-thread Tokio runtime and [`tokio::task::LocalSet`].
28282839
///
28292840
/// The server thread exits when the client connection is closed.
2830-
#[cfg(all(test, feature = "oci"))]
2841+
#[cfg(feature = "oci")]
28312842
pub(crate) fn spawn_in_process(
28322843
service: CfsctlService,
28332844
) -> std::io::Result<(zlink::unix::Connection, std::thread::JoinHandle<()>)> {

crates/composefs-integration-tests/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ composefs-ostree = { workspace = true }
4141
# proxy bindings (alongside the external `varlinkctl` CLI), and to assert on the
4242
# typed wire reply/error types.
4343
composefs-ctl = { workspace = true, features = ["oci"] }
44+
containers-image-proxy = { version = "0.11", default-features = false }
4445
hex = "0.4"
4546
libtest-mimic = "0.8"
4647
linkme = "0.3"

0 commit comments

Comments
 (0)