Skip to content

Commit bacbf43

Browse files
committed
ostree-ext: import container layers directly from overlay filesystem
When pulling container images from local containers-storage, detect overlay filesystem layers and import them directly to OSTree instead of processing tar streams. This allows to use reflinks on file systems that support them. Assisted-by: Claude Opus 4.5 Signed-off-by: Giuseppe Scrivano <gscrivan@redhat.com>
1 parent 14613a0 commit bacbf43

9 files changed

Lines changed: 507 additions & 27 deletions

File tree

crates/lib/src/cli.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@ async fn upgrade(
10471047
crate::deploy::pull_unified(repo, imgref, None, opts.quiet, prog.clone(), storage)
10481048
.await?
10491049
} else {
1050-
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?
1050+
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone(), Some(storage)).await?
10511051
};
10521052
let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
10531053
let fetched_digest = &fetched.manifest_digest;
@@ -1210,7 +1210,7 @@ async fn switch_ostree(
12101210
let fetched = if use_unified {
12111211
crate::deploy::pull_unified(repo, &target, None, opts.quiet, prog.clone(), storage).await?
12121212
} else {
1213-
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?
1213+
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone(), Some(storage)).await?
12141214
};
12151215

12161216
if !opts.retain {
@@ -1347,7 +1347,15 @@ async fn edit_ostree(
13471347
return crate::deploy::rollback(storage).await;
13481348
}
13491349

1350-
let fetched = crate::deploy::pull(repo, new_spec.image, None, opts.quiet, prog.clone()).await?;
1350+
let fetched = crate::deploy::pull(
1351+
repo,
1352+
new_spec.image,
1353+
None,
1354+
opts.quiet,
1355+
prog.clone(),
1356+
Some(storage),
1357+
)
1358+
.await?;
13511359

13521360
// TODO gc old layers here
13531361

crates/lib/src/deploy.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ pub(crate) async fn prepare_for_pull(
380380
repo: &ostree::Repo,
381381
imgref: &ImageReference,
382382
target_imgref: Option<&OstreeImageReference>,
383+
_store: Option<&Storage>,
383384
) -> Result<PreparedPullResult> {
384385
let imgref_canonicalized = imgref.clone().canonicalize()?;
385386
tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
@@ -388,6 +389,13 @@ pub(crate) async fn prepare_for_pull(
388389
if let Some(target) = target_imgref {
389390
imp.set_target(target);
390391
}
392+
393+
// Set storage root for direct access to the layer content when using containers-storage.
394+
// For regular pulls, images come from the host's default container storage.
395+
if ostree_imgref.imgref.transport == ostree_container::Transport::ContainerStorage {
396+
let storage_path = format!("{}/storage", crate::podman::CONTAINER_STORAGE);
397+
imp.set_storage_root(storage_path);
398+
}
391399
let prep = match imp.prepare().await? {
392400
PrepareResult::AlreadyPresent(c) => {
393401
println!("No changes in {imgref:#} => {}", c.manifest_digest);
@@ -484,6 +492,8 @@ pub(crate) async fn prepare_for_pull_unified(
484492

485493
// Use the preparation flow with the custom config
486494
let mut imp = new_importer_with_config(repo, &ostree_imgref, config).await?;
495+
// Set storage root for direct access to the layer content
496+
imp.set_storage_root(&storage_path);
487497
if let Some(target) = target_imgref {
488498
imp.set_target(target);
489499
}
@@ -642,8 +652,9 @@ pub(crate) async fn pull(
642652
target_imgref: Option<&OstreeImageReference>,
643653
quiet: bool,
644654
prog: ProgressWriter,
655+
store: Option<&Storage>,
645656
) -> Result<Box<ImageState>> {
646-
match prepare_for_pull(repo, imgref, target_imgref).await? {
657+
match prepare_for_pull(repo, imgref, target_imgref, store).await? {
647658
PreparedPullResult::AlreadyPresent(existing) => {
648659
// Log that the image was already present (Debug level since it's not actionable)
649660
const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";

crates/lib/src/install.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,13 @@ async fn install_container(
969969
)
970970
.await?
971971
} else {
972-
prepare_for_pull(repo, &spec_imgref, Some(&state.target_imgref)).await?
972+
prepare_for_pull(
973+
repo,
974+
&spec_imgref,
975+
Some(&state.target_imgref),
976+
Some(storage),
977+
)
978+
.await?
973979
};
974980

975981
let pulled_image = match prepared {
@@ -2513,6 +2519,7 @@ pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> {
25132519
None,
25142520
opts.quiet,
25152521
prog.clone(),
2522+
Some(sysroot),
25162523
)
25172524
.await?;
25182525
(fetched, new_spec)

crates/ostree-ext/src/container/store.rs

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ pub struct ImageImporter {
187187
offline: bool,
188188
/// If true, we have ostree v2024.3 or newer.
189189
ostree_v2024_3: bool,
190+
/// Optional containers-storage root path for direct access to the layer content
191+
storage_root: Option<Utf8PathBuf>,
190192

191193
layer_progress: Option<Sender<ImportProgress>>,
192194
layer_byte_progress: Option<tokio::sync::watch::Sender<Option<LayerProgress>>>,
@@ -530,6 +532,7 @@ impl ImageImporter {
530532
disable_gc: false,
531533
require_bootable: false,
532534
offline: false,
535+
storage_root: None,
533536
imgref: imgref.clone(),
534537
layer_progress: None,
535538
layer_byte_progress: None,
@@ -568,6 +571,13 @@ impl ImageImporter {
568571
self.disable_gc = true;
569572
}
570573

574+
/// Set the containers-storage root path for direct access to the layer content.
575+
/// When set, layers will be imported directly from the layer diff directory
576+
/// instead of being streamed through the proxy.
577+
pub fn set_storage_root(&mut self, path: impl Into<Utf8PathBuf>) {
578+
self.storage_root = Some(path.into());
579+
}
580+
571581
/// Determine if there is a new manifest, and if so return its digest.
572582
/// This will also serialize the new manifest and configuration into
573583
/// metadata associated with the image, so that invocations of `[query_cached]`
@@ -1120,16 +1130,6 @@ impl ImageImporter {
11201130
p.send(ImportProgress::DerivedLayerStarted(layer.layer.clone()))
11211131
.await?;
11221132
}
1123-
let (blob, driver, media_type) = super::unencapsulate::fetch_layer(
1124-
&proxy,
1125-
&import.proxy_img,
1126-
&import.manifest,
1127-
&layer.layer,
1128-
self.layer_byte_progress.as_ref(),
1129-
des_layers.as_ref(),
1130-
self.imgref.imgref.transport,
1131-
)
1132-
.await?;
11331133
// An important aspect of this is that we SELinux label the derived layers using
11341134
// the base policy.
11351135
let opts = crate::tar::WriteTarOptions {
@@ -1138,16 +1138,61 @@ impl ImageImporter {
11381138
allow_nonusr: root_is_transient,
11391139
retain_var: self.ostree_v2024_3,
11401140
};
1141-
let r = crate::tar::write_tar(
1142-
&self.repo,
1143-
blob,
1144-
media_type,
1145-
layer.ostree_ref.as_str(),
1146-
Some(opts),
1141+
1142+
let layer_index = import
1143+
.manifest
1144+
.layers()
1145+
.iter()
1146+
.position(|x| x == &layer.layer)
1147+
.ok_or_else(|| {
1148+
anyhow!("Layer {} not found in manifest", layer.layer.digest())
1149+
})?;
1150+
tracing::debug!(
1151+
"Processing layer {}: digest={}, ostree_ref={}, transport={:?}",
1152+
layer_index,
1153+
layer.layer.digest(),
1154+
layer.ostree_ref,
1155+
self.imgref.imgref.transport
11471156
);
1148-
let r = super::unencapsulate::join_fetch(r, driver)
1149-
.await
1150-
.with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?;
1157+
let layer_diff_path = super::unencapsulate::try_get_layer_diff_path(
1158+
self.storage_root.as_deref(),
1159+
des_layers.as_ref(),
1160+
layer_index,
1161+
self.imgref.imgref.transport,
1162+
)?;
1163+
1164+
let r = if let Some(ref path) = layer_diff_path {
1165+
tracing::info!("Importing layer {} from filesystem: {}", layer_index, path);
1166+
Self::import_layer_from_filesystem(&self.repo, path, &layer.ostree_ref, &opts)
1167+
.await?
1168+
} else {
1169+
// Fall back to blob access
1170+
let (blob, driver, media_type) = super::unencapsulate::fetch_layer(
1171+
&proxy,
1172+
&import.proxy_img,
1173+
&import.manifest,
1174+
&layer.layer,
1175+
self.layer_byte_progress.as_ref(),
1176+
des_layers.as_ref(),
1177+
self.imgref.imgref.transport,
1178+
)
1179+
.await?;
1180+
tracing::debug!(
1181+
"Importing layer {} from tar stream, media_type={:?}",
1182+
layer_index,
1183+
media_type
1184+
);
1185+
let r = crate::tar::write_tar(
1186+
&self.repo,
1187+
blob,
1188+
media_type,
1189+
layer.ostree_ref.as_str(),
1190+
Some(opts),
1191+
);
1192+
super::unencapsulate::join_fetch(r, driver)
1193+
.await
1194+
.with_context(|| format!("Parsing layer blob {}", layer.layer.digest()))?
1195+
};
11511196
tracing::debug!("Imported layer: {}", r.commit.as_str());
11521197
layer_commits.push(r.commit);
11531198
let filtered_owned = HashMap::from_iter(r.filtered.clone());
@@ -1233,6 +1278,55 @@ impl ImageImporter {
12331278
state.filtered_files = layer_filtered_content;
12341279
Ok(state)
12351280
}
1281+
1282+
/// Import a layer directly from filesystem path instead of tar stream.
1283+
/// This directly walks the filesystem and writes content objects to OSTree,
1284+
/// applying path transformations (e.g., /etc -> /usr/etc).
1285+
async fn import_layer_from_filesystem(
1286+
repo: &ostree::Repo,
1287+
layer_path: &Utf8Path,
1288+
target_ref: &str,
1289+
options: &crate::tar::WriteTarOptions,
1290+
) -> Result<crate::tar::WriteTarResult> {
1291+
tracing::info!(
1292+
"import_layer_from_filesystem: layer_path={}, target_ref={}",
1293+
layer_path,
1294+
target_ref
1295+
);
1296+
1297+
let config = crate::filesystem::FilesystemFilterConfig::from_tar_options(options);
1298+
let repo = repo.clone();
1299+
let layer_path = layer_path.to_owned();
1300+
let target_ref = target_ref.to_string();
1301+
1302+
// Run the synchronous import in a blocking task
1303+
let result = {
1304+
let repo = repo.clone();
1305+
crate::tokio_util::spawn_blocking_flatten(move || {
1306+
crate::filesystem::import_filesystem_to_ostree(&repo, &layer_path, &config)
1307+
})
1308+
.await?
1309+
};
1310+
1311+
// Cache the layer by setting the ref (matching write_tar behavior)
1312+
{
1313+
let target_ref = target_ref.clone();
1314+
let commit = result.commit.clone();
1315+
crate::tokio_util::spawn_blocking_flatten(move || {
1316+
repo.set_ref_immediate(None, &target_ref, Some(&commit), gio::Cancellable::NONE)?;
1317+
Ok::<_, anyhow::Error>(())
1318+
})
1319+
.await?;
1320+
}
1321+
1322+
tracing::info!(
1323+
"Successfully imported layer from filesystem: commit={}, ref={}",
1324+
result.commit,
1325+
target_ref
1326+
);
1327+
1328+
Ok(result)
1329+
}
12361330
}
12371331

12381332
/// List all images stored

crates/ostree-ext/src/container/unencapsulate.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
use crate::container::store::LayerProgress;
3535

3636
use super::*;
37+
use anyhow::Context as _;
38+
use camino::{Utf8Path, Utf8PathBuf};
3739
use containers_image_proxy::{ImageProxy, OpenedImage};
3840
use fn_error_context::context;
3941
use futures_util::{Future, FutureExt};
@@ -185,6 +187,108 @@ pub async fn unencapsulate(repo: &ostree::Repo, imgref: &OstreeImageReference) -
185187
importer.unencapsulate().await
186188
}
187189

190+
/// Try to get the diff path for a layer in containers-storage.
191+
/// Returns Some(path) if the layer diff directory is available, None to use blob access.
192+
///
193+
/// The `storage_root` parameter specifies the containers-storage root directory.
194+
/// If None, direct filesystem access is not attempted.
195+
pub(crate) fn try_get_layer_diff_path(
196+
storage_root: Option<&Utf8Path>,
197+
layer_info: Option<&Vec<containers_image_proxy::ConvertedLayerInfo>>,
198+
layer_index: usize,
199+
transport_src: Transport,
200+
) -> Result<Option<Utf8PathBuf>> {
201+
match (transport_src, storage_root) {
202+
(Transport::ContainerStorage, Some(storage_root)) => {
203+
get_layer_diff_path(storage_root, layer_info, layer_index)
204+
}
205+
_ => Ok(None),
206+
}
207+
}
208+
209+
/// Entry in the overlay-layers/layers.json file
210+
#[derive(serde::Deserialize, Debug)]
211+
struct OverlayLayerEntry {
212+
/// The directory name under overlay/
213+
id: String,
214+
/// Uncompressed diff digest (e.g., "sha256:...")
215+
#[serde(rename = "diff-digest")]
216+
diff_digest: Option<String>,
217+
/// Compressed diff digest (e.g., "sha256:...")
218+
#[serde(rename = "compressed-diff-digest")]
219+
compressed_diff_digest: Option<String>,
220+
}
221+
222+
/// Look up the overlay directory ID from layers.json given a digest
223+
fn lookup_layer_id_from_digest(storage_root: &Utf8Path, digest: &str) -> Result<Option<String>> {
224+
let layers_json_path = storage_root.join("overlay-layers/layers.json");
225+
226+
let file = match std::fs::File::open(&layers_json_path) {
227+
Ok(f) => f,
228+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
229+
return Ok(None);
230+
}
231+
Err(e) => return Err(anyhow::Error::new(e).context("Failed to open layers.json")),
232+
};
233+
234+
let reader = std::io::BufReader::new(file);
235+
let layers: Vec<OverlayLayerEntry> =
236+
serde_json::from_reader(reader).context("Failed to parse layers.json")?;
237+
238+
// Search for matching digest in both diff-digest and compressed-diff-digest
239+
for layer in &layers {
240+
let matches = layer.diff_digest.as_deref() == Some(digest)
241+
|| layer.compressed_diff_digest.as_deref() == Some(digest);
242+
if matches {
243+
return Ok(Some(layer.id.clone()));
244+
}
245+
}
246+
247+
Ok(None)
248+
}
249+
250+
/// Get the diff directory path for a layer in containers-storage
251+
fn get_layer_diff_path(
252+
storage_root: &Utf8Path,
253+
layer_info: Option<&Vec<containers_image_proxy::ConvertedLayerInfo>>,
254+
layer_index: usize,
255+
) -> Result<Option<Utf8PathBuf>> {
256+
let Some(info) = layer_info else {
257+
return Ok(None);
258+
};
259+
260+
let Some(layer) = info.get(layer_index) else {
261+
return Ok(None);
262+
};
263+
264+
// Get the digest string (includes "sha256:" prefix)
265+
let digest_str = layer.digest.to_string();
266+
267+
// Look up the layer ID from layers.json
268+
let Some(layer_id) = lookup_layer_id_from_digest(storage_root, &digest_str)? else {
269+
return Ok(None);
270+
};
271+
272+
// Check if this is a composefs layer (not supported yet)
273+
let composefs_blob_path = storage_root.join(format!(
274+
"overlay/{}/composefs-data/composefs.blob",
275+
layer_id
276+
));
277+
if composefs_blob_path.exists() {
278+
return Ok(None);
279+
}
280+
281+
// Construct the layer diff path: $STORAGE/overlay/$LAYER_ID/diff
282+
let layer_diff_path = storage_root.join(format!("overlay/{}/diff", layer_id));
283+
284+
// Check if the layer diff directory exists and is a directory
285+
if !layer_diff_path.is_dir() {
286+
return Ok(None);
287+
}
288+
289+
Ok(Some(layer_diff_path))
290+
}
291+
188292
/// A wrapper for [`get_blob`] which fetches a layer and decompresses it.
189293
pub(crate) async fn fetch_layer<'a>(
190294
proxy: &'a ImageProxy,

0 commit comments

Comments
 (0)