Skip to content

Commit 7785939

Browse files
committed
oci: Expand ImportStats for zero-copy tracking and add PullOptions
Extend ImportStats with reflink/hardlink counters and byte totals, plus layer-level tracking (layers, layers_already_present). The Display impl now shows a detailed breakdown when zero-copy methods were used while preserving the existing compact format for copy-only imports. Add PullOptions struct to the pull() signature, preparing for the containers-storage import path which needs extra knobs (zerocopy mode, explicit storage root, additional image stores). Visibility changes (pub(crate) on helpers, pub on ContentAndVerity) prepare for the cstor module to reuse these internals. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 074424e commit 7785939

2 files changed

Lines changed: 197 additions & 17 deletions

File tree

crates/composefs-oci/src/lib.rs

Lines changed: 189 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
//! - Pulling container images from registries using skopeo
99
//! - Converting OCI image layers from tar format to composefs split streams
1010
//! - Creating mountable filesystems from OCI image configurations
11+
//! - Importing from containers-storage with zero-copy reflinks (optional feature)
12+
1113
#![forbid(unsafe_code)]
1214

1315
pub mod boot;
@@ -67,27 +69,55 @@ pub use skopeo::pull_image;
6769
/// Statistics from an image import operation.
6870
#[derive(Debug, Clone, Default)]
6971
pub struct ImportStats {
70-
/// Number of objects stored via copy.
72+
/// Number of layers in the image.
73+
pub layers: u64,
74+
/// Number of layers that were already present (skipped).
75+
pub layers_already_present: u64,
76+
/// Number of objects stored via regular copy.
7177
pub objects_copied: u64,
78+
/// Number of objects stored via reflink (zero-copy).
79+
pub objects_reflinked: u64,
80+
/// Number of objects stored via hardlink (zero-copy).
81+
pub objects_hardlinked: u64,
7282
/// Number of objects that already existed (deduplicated).
7383
pub objects_already_present: u64,
74-
/// Total bytes stored as new objects.
84+
/// Total bytes stored via regular copy.
7585
pub bytes_copied: u64,
86+
/// Total bytes stored via reflink.
87+
pub bytes_reflinked: u64,
88+
/// Total bytes stored via hardlink.
89+
pub bytes_hardlinked: u64,
7690
/// Total bytes inlined in splitstreams (small files + headers).
7791
pub bytes_inlined: u64,
7892
}
7993

8094
impl ImportStats {
95+
/// Total number of new objects stored (copied + reflinked + hardlinked).
96+
pub fn new_objects(&self) -> u64 {
97+
self.objects_copied + self.objects_reflinked + self.objects_hardlinked
98+
}
99+
81100
/// Total number of objects processed (new + already present).
82101
pub fn total_objects(&self) -> u64 {
83-
self.objects_copied + self.objects_already_present
102+
self.new_objects() + self.objects_already_present
103+
}
104+
105+
/// Total bytes stored as new objects (copied + reflinked + hardlinked).
106+
pub fn new_bytes(&self) -> u64 {
107+
self.bytes_copied + self.bytes_reflinked + self.bytes_hardlinked
84108
}
85109

86110
/// Merge another `ImportStats` into this one.
87111
pub fn merge(&mut self, other: &ImportStats) {
112+
self.layers += other.layers;
113+
self.layers_already_present += other.layers_already_present;
88114
self.objects_copied += other.objects_copied;
115+
self.objects_reflinked += other.objects_reflinked;
116+
self.objects_hardlinked += other.objects_hardlinked;
89117
self.objects_already_present += other.objects_already_present;
90118
self.bytes_copied += other.bytes_copied;
119+
self.bytes_reflinked += other.bytes_reflinked;
120+
self.bytes_hardlinked += other.bytes_hardlinked;
91121
self.bytes_inlined += other.bytes_inlined;
92122
}
93123

@@ -103,6 +133,14 @@ impl ImportStats {
103133
stats.objects_copied += 1;
104134
stats.bytes_copied += size;
105135
}
136+
ObjectStoreMethod::Reflinked => {
137+
stats.objects_reflinked += 1;
138+
stats.bytes_reflinked += size;
139+
}
140+
ObjectStoreMethod::Hardlinked => {
141+
stats.objects_hardlinked += 1;
142+
stats.bytes_hardlinked += size;
143+
}
106144
ObjectStoreMethod::AlreadyPresent => {
107145
stats.objects_already_present += 1;
108146
}
@@ -114,17 +152,83 @@ impl ImportStats {
114152

115153
impl std::fmt::Display for ImportStats {
116154
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117-
write!(
118-
f,
119-
"{} new + {} already present objects; {} stored, {} inlined",
120-
self.objects_copied,
121-
self.objects_already_present,
122-
indicatif::HumanBytes(self.bytes_copied),
123-
indicatif::HumanBytes(self.bytes_inlined),
124-
)
155+
let has_zerocopy = self.objects_reflinked > 0 || self.objects_hardlinked > 0;
156+
if has_zerocopy {
157+
// Show detailed breakdown when zero-copy methods were used
158+
let mut parts = Vec::new();
159+
if self.objects_reflinked > 0 {
160+
parts.push(format!("{} reflinked", self.objects_reflinked));
161+
}
162+
if self.objects_hardlinked > 0 {
163+
parts.push(format!("{} hardlinked", self.objects_hardlinked));
164+
}
165+
parts.push(format!("{} copied", self.objects_copied));
166+
parts.push(format!("{} already present", self.objects_already_present));
167+
write!(f, "{} objects; ", parts.join(" + "))?;
168+
169+
let mut byte_parts = Vec::new();
170+
if self.objects_reflinked > 0 {
171+
byte_parts.push(format!(
172+
"{} reflinked",
173+
indicatif::HumanBytes(self.bytes_reflinked)
174+
));
175+
}
176+
if self.objects_hardlinked > 0 {
177+
byte_parts.push(format!(
178+
"{} hardlinked",
179+
indicatif::HumanBytes(self.bytes_hardlinked)
180+
));
181+
}
182+
byte_parts.push(format!(
183+
"{} copied",
184+
indicatif::HumanBytes(self.bytes_copied)
185+
));
186+
byte_parts.push(format!(
187+
"{} inlined",
188+
indicatif::HumanBytes(self.bytes_inlined)
189+
));
190+
write!(f, "{}", byte_parts.join(", "))
191+
} else {
192+
write!(
193+
f,
194+
"{} new + {} already present objects; {} stored, {} inlined",
195+
self.objects_copied,
196+
self.objects_already_present,
197+
indicatif::HumanBytes(self.bytes_copied),
198+
indicatif::HumanBytes(self.bytes_inlined),
199+
)
200+
}
125201
}
126202
}
127203

204+
/// Options for a [`pull`] operation.
205+
///
206+
/// Use `Default::default()` for the common case (skopeo transport, no
207+
/// zero-copy requirement).
208+
#[derive(Debug, Default)]
209+
pub struct PullOptions<'a> {
210+
/// Image proxy configuration passed to skopeo (ignored for
211+
/// `containers-storage:` references).
212+
pub img_proxy_config: Option<ImageProxyConfig>,
213+
214+
/// If `true`, the containers-storage import path will error instead of
215+
/// falling back to a data copy when neither reflink nor hardlink succeeds.
216+
/// Intended for bootc's unified storage layout where the composefs repo
217+
/// and containers-storage are always on the same filesystem.
218+
pub zerocopy: bool,
219+
220+
/// Explicit containers-storage root. When set, auto-discovery is skipped
221+
/// and only this path (plus any `additional_image_stores`) is searched.
222+
/// Only relevant for `containers-storage:` references.
223+
pub storage_root: Option<&'a std::path::Path>,
224+
225+
/// Additional read-only image stores to search beyond the primary
226+
/// (auto-discovered or explicit) store. Equivalent to the
227+
/// `additionalimagestore=` option in containers/storage.
228+
/// Only relevant for `containers-storage:` references.
229+
pub additional_image_stores: &'a [&'a std::path::Path],
230+
}
231+
128232
/// Result of a pull operation.
129233
#[derive(Debug)]
130234
pub struct PullResult<ObjectID> {
@@ -136,7 +240,8 @@ pub struct PullResult<ObjectID> {
136240
pub stats: ImportStats,
137241
}
138242

139-
type ContentAndVerity<ObjectID> = (OciDigest, ObjectID);
243+
/// A tuple of (content digest, fs-verity ObjectID).
244+
pub type ContentAndVerity<ObjectID> = (OciDigest, ObjectID);
140245

141246
/// Parsed OCI config and its associated references.
142247
pub struct OpenConfig<ObjectID> {
@@ -160,11 +265,11 @@ impl<ObjectID: std::fmt::Debug> std::fmt::Debug for OpenConfig<ObjectID> {
160265
}
161266
}
162267

163-
fn layer_identifier(diff_id: &OciDigest) -> String {
268+
pub(crate) fn layer_identifier(diff_id: &OciDigest) -> String {
164269
format!("oci-layer-{diff_id}")
165270
}
166271

167-
fn config_identifier(config: &OciDigest) -> String {
272+
pub(crate) fn config_identifier(config: &OciDigest) -> String {
168273
format!("oci-config-{config}")
169274
}
170275

@@ -223,14 +328,21 @@ pub fn ls_layer<ObjectID: FsVerityHashValue>(
223328

224329
/// Pull the target image, and add the provided tag. If this is a mountable
225330
/// image (i.e. not an artifact), it is *not* unpacked by default.
331+
///
332+
/// When the `containers-storage` feature is enabled and the image reference
333+
/// starts with `containers-storage:`, this uses the native cstor import path
334+
/// which supports zero-copy reflinks/hardlinks. Otherwise, it uses skopeo.
335+
///
336+
/// See [`PullOptions`] for tunable knobs (zero-copy mode, extra storage
337+
/// roots, image proxy configuration).
226338
pub async fn pull<ObjectID: FsVerityHashValue>(
227339
repo: &Arc<Repository<ObjectID>>,
228340
imgref: &str,
229341
reference: Option<&str>,
230-
img_proxy_config: Option<ImageProxyConfig>,
342+
opts: PullOptions<'_>,
231343
) -> Result<PullResult<ObjectID>> {
232344
let (config_digest, config_verity, stats) =
233-
skopeo::pull(repo, imgref, reference, img_proxy_config).await?;
345+
skopeo::pull(repo, imgref, reference, opts.img_proxy_config).await?;
234346
Ok(crate::PullResult {
235347
config_digest,
236348
config_verity,
@@ -1075,23 +1187,83 @@ mod test {
10751187

10761188
#[test]
10771189
fn test_import_stats_display() {
1190+
// Copy-only stats (no reflinks)
10781191
let stats = ImportStats {
10791192
objects_copied: 42,
10801193
objects_already_present: 100,
10811194
bytes_copied: 1_500_000,
10821195
bytes_inlined: 800,
1196+
..Default::default()
10831197
};
10841198
assert_eq!(
10851199
stats.to_string(),
10861200
"42 new + 100 already present objects; 1.43 MiB stored, 800 B inlined"
10871201
);
1202+
assert_eq!(stats.total_objects(), 142);
1203+
assert_eq!(stats.new_objects(), 42);
1204+
assert_eq!(stats.new_bytes(), 1_500_000);
1205+
1206+
// Stats with reflinks
1207+
let reflink_stats = ImportStats {
1208+
objects_reflinked: 30,
1209+
objects_copied: 12,
1210+
objects_already_present: 100,
1211+
bytes_reflinked: 1_000_000,
1212+
bytes_copied: 500_000,
1213+
bytes_inlined: 800,
1214+
..Default::default()
1215+
};
1216+
assert_eq!(
1217+
reflink_stats.to_string(),
1218+
"30 reflinked + 12 copied + 100 already present objects; 976.56 KiB reflinked, 488.28 KiB copied, 800 B inlined"
1219+
);
1220+
assert_eq!(reflink_stats.total_objects(), 142);
1221+
assert_eq!(reflink_stats.new_objects(), 42);
1222+
assert_eq!(reflink_stats.new_bytes(), 1_500_000);
1223+
1224+
// Stats with hardlinks only
1225+
let hardlink_stats = ImportStats {
1226+
objects_hardlinked: 20,
1227+
objects_copied: 5,
1228+
objects_already_present: 50,
1229+
bytes_hardlinked: 800_000,
1230+
bytes_copied: 200_000,
1231+
bytes_inlined: 400,
1232+
..Default::default()
1233+
};
1234+
assert_eq!(
1235+
hardlink_stats.to_string(),
1236+
"20 hardlinked + 5 copied + 50 already present objects; 781.25 KiB hardlinked, 195.31 KiB copied, 400 B inlined"
1237+
);
1238+
assert_eq!(hardlink_stats.total_objects(), 75);
1239+
assert_eq!(hardlink_stats.new_objects(), 25);
1240+
assert_eq!(hardlink_stats.new_bytes(), 1_000_000);
1241+
1242+
// Stats with both reflinks and hardlinks
1243+
let mixed_stats = ImportStats {
1244+
objects_reflinked: 10,
1245+
objects_hardlinked: 15,
1246+
objects_copied: 5,
1247+
objects_already_present: 70,
1248+
bytes_reflinked: 500_000,
1249+
bytes_hardlinked: 750_000,
1250+
bytes_copied: 250_000,
1251+
bytes_inlined: 600,
1252+
..Default::default()
1253+
};
1254+
assert_eq!(
1255+
mixed_stats.to_string(),
1256+
"10 reflinked + 15 hardlinked + 5 copied + 70 already present objects; 488.28 KiB reflinked, 732.42 KiB hardlinked, 244.14 KiB copied, 600 B inlined"
1257+
);
1258+
assert_eq!(mixed_stats.total_objects(), 100);
1259+
assert_eq!(mixed_stats.new_objects(), 30);
1260+
assert_eq!(mixed_stats.new_bytes(), 1_500_000);
10881261

10891262
let empty = ImportStats::default();
10901263
assert_eq!(
10911264
empty.to_string(),
10921265
"0 new + 0 already present objects; 0 B stored, 0 B inlined"
10931266
);
10941267
assert_eq!(empty.total_objects(), 0);
1095-
assert_eq!(stats.total_objects(), 142);
10961268
}
10971269
}

crates/composefs-oci/src/skopeo.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ impl<ObjectID: FsVerityHashValue> ImageOp<ObjectID> {
242242
stats.objects_copied += 1;
243243
stats.bytes_copied += size;
244244
}
245+
ObjectStoreMethod::Reflinked => {
246+
stats.objects_reflinked += 1;
247+
stats.bytes_reflinked += size;
248+
}
249+
ObjectStoreMethod::Hardlinked => {
250+
stats.objects_hardlinked += 1;
251+
stats.bytes_hardlinked += size;
252+
}
245253
ObjectStoreMethod::AlreadyPresent => {
246254
stats.objects_already_present += 1;
247255
}

0 commit comments

Comments
 (0)