Skip to content

Commit b0b5b01

Browse files
committed
cstor: Add import statistics and route pull through library
Add ImportStats struct to track containers-storage import operations: - Count of layers (total and already present) - Count of objects (reflinked, copied, already present) - Bytes (reflinked, copied, inlined) The repository now provides ensure_object_from_file_with_stats() and ObjectStoreMethod enum to report how each object was stored. The pull() function in composefs-oci now automatically routes containers-storage: references to the native cstor import path, returning a PullResult struct that includes optional ImportStats. This keeps the routing logic in the library rather than CLI. cfsctl prints statistics after a containers-storage pull showing whether reflinks were used for zero-copy import. Example output: Import statistics: layers: 1 (0 already present) objects: 16 total (16 reflinked, 0 copied, 0 already present) reflinked: 4.22 MiB (zero-copy) inlined: 228.90 KiB Assisted-by: OpenCode (Opus 4.5) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 14a3bb2 commit b0b5b01

7 files changed

Lines changed: 276 additions & 110 deletions

File tree

crates/cfsctl/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ composefs-oci = { workspace = true, optional = true }
2727
composefs-http = { workspace = true, optional = true }
2828
env_logger = { version = "0.11.0", default-features = false }
2929
hex = { version = "0.4.0", default-features = false }
30+
indicatif = { version = "0.17.0", default-features = false }
3031
rustix = { version = "1.0.0", default-features = false, features = ["fs", "process"] }
3132
tokio = { version = "1.24.2", default-features = false }
3233

crates/cfsctl/src/main.rs

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -299,27 +299,38 @@ where
299299
}
300300
OciCommand::Pull { ref image, name } => {
301301
let repo = Arc::new(repo);
302-
let (digest, verity) = {
303-
#[cfg(feature = "containers-storage")]
304-
if let Some(image_id) =
305-
composefs_oci::cstor::parse_containers_storage_ref(image)
306-
{
307-
composefs_oci::cstor::import_from_containers_storage(
308-
&repo,
309-
image_id,
310-
name.as_deref(),
311-
)
312-
.await?
313-
} else {
314-
composefs_oci::pull(&repo, image, name.as_deref(), None).await?
315-
}
302+
let result = composefs_oci::pull(&repo, image, name.as_deref(), None).await?;
316303

317-
#[cfg(not(feature = "containers-storage"))]
318-
composefs_oci::pull(&repo, image, name.as_deref(), None).await?
319-
};
304+
println!("config {}", result.config_digest);
305+
println!("verity {}", result.config_verity.to_hex());
320306

321-
println!("config {digest}");
322-
println!("verity {}", verity.to_hex());
307+
// Print import statistics if available (containers-storage imports)
308+
#[cfg(feature = "containers-storage")]
309+
if let Some(stats) = result.stats {
310+
println!();
311+
println!("Import statistics:");
312+
println!(
313+
" layers: {} ({} already present)",
314+
stats.layers, stats.layers_already_present
315+
);
316+
println!(
317+
" objects: {} total ({} reflinked, {} copied, {} already present)",
318+
stats.total_objects(),
319+
stats.objects_reflinked,
320+
stats.objects_copied,
321+
stats.objects_already_present
322+
);
323+
if stats.used_reflinks() {
324+
println!(
325+
" reflinked: {} (zero-copy)",
326+
indicatif::HumanBytes(stats.bytes_reflinked)
327+
);
328+
}
329+
if stats.bytes_copied > 0 {
330+
println!(" copied: {}", indicatif::HumanBytes(stats.bytes_copied));
331+
}
332+
println!(" inlined: {}", indicatif::HumanBytes(stats.bytes_inlined));
333+
}
323334
}
324335
OciCommand::Seal {
325336
ref config_name,

crates/composefs-oci/src/cstor.rs

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
//! use composefs_oci::cstor::import_from_containers_storage;
2626
//!
2727
//! let repo = Arc::new(Repository::open_user()?);
28-
//! let result = import_from_containers_storage(&repo, "sha256:abc123...", None).await?;
28+
//! let (result, stats) = import_from_containers_storage(&repo, "sha256:abc123...", None).await?;
2929
//! println!("Imported config: {}", result.0);
30+
//! println!("Stats: {:?}", stats);
3031
//! ```
3132
3233
use std::io::{Read, Seek, SeekFrom};
@@ -38,13 +39,67 @@ use indicatif::{ProgressBar, ProgressStyle};
3839
use sha2::Digest;
3940
use tokio::task::spawn_blocking;
4041

41-
use composefs::{fsverity::FsVerityHashValue, repository::Repository, INLINE_CONTENT_MAX};
42+
use composefs::{
43+
fsverity::FsVerityHashValue,
44+
repository::{ObjectStoreMethod, Repository},
45+
INLINE_CONTENT_MAX,
46+
};
4247

4348
use cstorage::{Image, Layer, Storage, TarSplitFdStream, TarSplitItem};
4449

4550
use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE};
4651
use crate::{config_identifier, layer_identifier, ContentAndVerity};
4752

53+
/// Statistics from a containers-storage import operation.
54+
#[derive(Debug, Clone, Default)]
55+
pub struct ImportStats {
56+
/// Number of layers in the image.
57+
pub layers: u64,
58+
/// Number of layers that were already present (skipped).
59+
pub layers_already_present: u64,
60+
/// Number of objects stored via reflink (zero-copy).
61+
pub objects_reflinked: u64,
62+
/// Number of objects stored via regular copy (reflink not supported).
63+
pub objects_copied: u64,
64+
/// Number of objects that were already present (deduplicated).
65+
pub objects_already_present: u64,
66+
/// Total bytes stored via reflink.
67+
pub bytes_reflinked: u64,
68+
/// Total bytes stored via regular copy.
69+
pub bytes_copied: u64,
70+
/// Total bytes inlined in splitstreams (small files + headers).
71+
pub bytes_inlined: u64,
72+
}
73+
74+
impl ImportStats {
75+
/// Merge stats from another ImportStats into this one.
76+
pub fn merge(&mut self, other: &ImportStats) {
77+
self.layers += other.layers;
78+
self.layers_already_present += other.layers_already_present;
79+
self.objects_reflinked += other.objects_reflinked;
80+
self.objects_copied += other.objects_copied;
81+
self.objects_already_present += other.objects_already_present;
82+
self.bytes_reflinked += other.bytes_reflinked;
83+
self.bytes_copied += other.bytes_copied;
84+
self.bytes_inlined += other.bytes_inlined;
85+
}
86+
87+
/// Returns true if any objects were stored via reflink.
88+
pub fn used_reflinks(&self) -> bool {
89+
self.objects_reflinked > 0
90+
}
91+
92+
/// Total number of objects processed.
93+
pub fn total_objects(&self) -> u64 {
94+
self.objects_reflinked + self.objects_copied + self.objects_already_present
95+
}
96+
97+
/// Total bytes processed (external objects only, not inline).
98+
pub fn total_external_bytes(&self) -> u64 {
99+
self.bytes_reflinked + self.bytes_copied
100+
}
101+
}
102+
48103
/// Import a container image from containers-storage into the composefs repository.
49104
///
50105
/// This function reads an image from the local containers-storage (podman/buildah)
@@ -56,12 +111,12 @@ use crate::{config_identifier, layer_identifier, ContentAndVerity};
56111
/// * `reference` - Optional reference name to assign to the imported config
57112
///
58113
/// # Returns
59-
/// A tuple of (config_digest, config_verity_id) for the imported image.
114+
/// A tuple of ((config_digest, config_verity_id), import_stats).
60115
pub async fn import_from_containers_storage<ObjectID: FsVerityHashValue>(
61116
repo: &Arc<Repository<ObjectID>>,
62117
image_id: &str,
63118
reference: Option<&str>,
64-
) -> Result<ContentAndVerity<ObjectID>> {
119+
) -> Result<(ContentAndVerity<ObjectID>, ImportStats)> {
65120
let repo = Arc::clone(repo);
66121
let image_id = image_id.to_owned();
67122
let reference = reference.map(|s| s.to_owned());
@@ -81,7 +136,9 @@ fn import_from_containers_storage_blocking<ObjectID: FsVerityHashValue>(
81136
repo: &Arc<Repository<ObjectID>>,
82137
image_id: &str,
83138
reference: Option<&str>,
84-
) -> Result<ContentAndVerity<ObjectID>> {
139+
) -> Result<(ContentAndVerity<ObjectID>, ImportStats)> {
140+
let mut stats = ImportStats::default();
141+
85142
// Open containers-storage
86143
let storage = Storage::discover().context("Failed to discover containers-storage")?;
87144

@@ -112,6 +169,8 @@ fn import_from_containers_storage_blocking<ObjectID: FsVerityHashValue>(
112169
diff_ids.len()
113170
);
114171

172+
stats.layers = storage_layer_ids.len() as u64;
173+
115174
// Import each layer with progress bar
116175
let progress = ProgressBar::new(storage_layer_ids.len() as u64);
117176
progress.set_style(
@@ -128,12 +187,15 @@ fn import_from_containers_storage_blocking<ObjectID: FsVerityHashValue>(
128187

129188
let layer_verity = if let Some(existing) = repo.has_stream(&content_id)? {
130189
progress.set_message(format!("Already have {short_id}..."));
190+
stats.layers_already_present += 1;
131191
existing
132192
} else {
133193
progress.set_message(format!("Importing {short_id}..."));
134194
let layer = Layer::open(&storage, storage_layer_id)
135195
.with_context(|| format!("Failed to open layer {}", storage_layer_id))?;
136-
import_layer_with_writer(repo, &storage, &layer, diff_id)?
196+
let (verity, layer_stats) = import_layer_with_writer(repo, &storage, &layer, diff_id)?;
197+
stats.merge(&layer_stats);
198+
verity
137199
};
138200

139201
layer_refs.push((diff_id.clone(), layer_verity));
@@ -165,11 +227,12 @@ fn import_from_containers_storage_blocking<ObjectID: FsVerityHashValue>(
165227

166228
// Write config inline
167229
writer.write_inline(&config_json);
230+
stats.bytes_inlined += config_json.len() as u64;
168231

169232
repo.write_stream(writer, &content_id, reference)?
170233
};
171234

172-
Ok((config_digest, config_verity))
235+
Ok(((config_digest, config_verity), stats))
173236
}
174237

175238
/// Import a single layer from containers-storage using the writer pattern.
@@ -178,12 +241,16 @@ fn import_from_containers_storage_blocking<ObjectID: FsVerityHashValue>(
178241
/// - For large files: reflinks the file content to the objects directory
179242
/// - For small files: embeds content inline in the splitstream
180243
/// - Writes tar headers and padding as inline data
244+
///
245+
/// Returns the layer's verity ID and import statistics for this layer.
181246
fn import_layer_with_writer<ObjectID: FsVerityHashValue>(
182247
repo: &Arc<Repository<ObjectID>>,
183248
storage: &Storage,
184249
layer: &Layer,
185250
diff_id: &str,
186-
) -> Result<ObjectID> {
251+
) -> Result<(ObjectID, ImportStats)> {
252+
let mut stats = ImportStats::default();
253+
187254
let mut stream = TarSplitFdStream::new(storage, layer)
188255
.with_context(|| format!("Failed to create tar-split stream for layer {}", layer.id()))?;
189256

@@ -194,6 +261,7 @@ fn import_layer_with_writer<ObjectID: FsVerityHashValue>(
194261
match item {
195262
TarSplitItem::Segment(bytes) => {
196263
// Write raw segment bytes (tar headers, padding) as inline data
264+
stats.bytes_inlined += bytes.len() as u64;
197265
writer.write_inline(&bytes);
198266
}
199267
TarSplitItem::FileContent { fd, size, name } => {
@@ -202,10 +270,24 @@ fn import_layer_with_writer<ObjectID: FsVerityHashValue>(
202270

203271
if size as usize > INLINE_CONTENT_MAX {
204272
// Large file: use reflink to store as external object
205-
let object_id = repo
206-
.ensure_object_from_file(&file, size)
273+
let (object_id, method) = repo
274+
.ensure_object_from_file_with_stats(&file, size)
207275
.with_context(|| format!("Failed to store object for {}", name))?;
208276

277+
match method {
278+
ObjectStoreMethod::Reflinked => {
279+
stats.objects_reflinked += 1;
280+
stats.bytes_reflinked += size;
281+
}
282+
ObjectStoreMethod::Copied => {
283+
stats.objects_copied += 1;
284+
stats.bytes_copied += size;
285+
}
286+
ObjectStoreMethod::AlreadyPresent => {
287+
stats.objects_already_present += 1;
288+
}
289+
}
290+
209291
writer.add_external_size(size);
210292
writer.write_reference(object_id)?;
211293
} else {
@@ -214,14 +296,16 @@ fn import_layer_with_writer<ObjectID: FsVerityHashValue>(
214296
let mut file = file;
215297
file.seek(SeekFrom::Start(0))?;
216298
file.read_exact(&mut content)?;
299+
stats.bytes_inlined += size;
217300
writer.write_inline(&content);
218301
}
219302
}
220303
}
221304
}
222305

223306
// Write the stream with the content identifier
224-
repo.write_stream(writer, &content_id, None)
307+
let verity = repo.write_stream(writer, &content_id, None)?;
308+
Ok((verity, stats))
225309
}
226310

227311
/// Check if an image reference uses the containers-storage transport.

crates/composefs-oci/src/lib.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,50 @@ pub fn ls_layer<ObjectID: FsVerityHashValue>(
8181
Ok(())
8282
}
8383

84+
/// Result of a pull operation.
85+
#[derive(Debug)]
86+
pub struct PullResult<ObjectID> {
87+
/// The config digest (sha256:...).
88+
pub config_digest: String,
89+
/// The fs-verity hash of the config splitstream.
90+
pub config_verity: ObjectID,
91+
/// Import statistics (only present for containers-storage imports).
92+
#[cfg(feature = "containers-storage")]
93+
pub stats: Option<cstor::ImportStats>,
94+
}
95+
8496
/// Pull the target image, and add the provided tag. If this is a mountable
8597
/// image (i.e. not an artifact), it is *not* unpacked by default.
98+
///
99+
/// When the `containers-storage` feature is enabled and the image reference
100+
/// starts with `containers-storage:`, this uses the native cstor import path
101+
/// which supports zero-copy reflinks. Otherwise, it uses skopeo.
86102
pub async fn pull<ObjectID: FsVerityHashValue>(
87103
repo: &Arc<Repository<ObjectID>>,
88104
imgref: &str,
89105
reference: Option<&str>,
90106
img_proxy_config: Option<ImageProxyConfig>,
91-
) -> Result<(String, ObjectID)> {
92-
skopeo::pull(repo, imgref, reference, img_proxy_config).await
107+
) -> Result<PullResult<ObjectID>> {
108+
#[cfg(feature = "containers-storage")]
109+
if let Some(image_id) = cstor::parse_containers_storage_ref(imgref) {
110+
let ((config_digest, config_verity), stats) =
111+
cstor::import_from_containers_storage(repo, image_id, reference).await?;
112+
return Ok(PullResult {
113+
config_digest,
114+
config_verity,
115+
stats: Some(stats),
116+
});
117+
}
118+
119+
let (config_digest, config_verity) =
120+
skopeo::pull(repo, imgref, reference, img_proxy_config).await?;
121+
122+
Ok(PullResult {
123+
config_digest,
124+
config_verity,
125+
#[cfg(feature = "containers-storage")]
126+
stats: None,
127+
})
93128
}
94129

95130
fn hash(bytes: &[u8]) -> String {

0 commit comments

Comments
 (0)