Skip to content

Commit 4f3055d

Browse files
committed
unified-storage: Wire composefs-first pull pipeline into ostree deploy path
Add an end-to-end unified storage path for the ostree backend on reflink filesystems. When the composefs repo is present and unified storage has been enabled (via `bootc image set-unified`), `pull_auto` now routes through `pull_via_composefs_unified` which: 1. Pulls the image into bootc-owned containers-storage via `pull_composefs_unified` (zero-copy reflinks from the registry blobs). 2. Synthesizes an ostree commit from the composefs repo via `import_from_composefs_repo` + FICLONE, marking it META_COMPOSEFS_SYNTHESIZED so downstream code knows no per-layer blob refs exist. Unified storage mode is tracked by a new flag file `/sysroot/composefs/bootc.json` (BootcRepoMeta), written atomically on the first successful set-unified call and read cheaply on every pull_auto to decide which path to take. This avoids the previous heuristic that checked per-image presence in containers-storage and broke across `bootc switch` to a new image reference. The old ostree-native unified pull path (new_importer_with_config / check_disk_space_unified / prepare_for_pull_unified / pull_unified) is removed — it was dead code since the composefs pipeline was introduced. `bootc image copy-to-storage` is fixed for synthesized commits: because the synthesized ostree commit carries no per-layer blob refs, `ostree_ext::container::store::export()` would error out with "Refspec not found". `push_entrypoint` now detects META_COMPOSEFS_SYNTHESIZED on the resolved commit, reads the manifest and config from the commit metadata, and delegates to the composefs layer-streaming export path instead. The composefs export helper (`export_repo_to_image`) is refactored: the streaming core is extracted into `export_composefs_to_dest` so it can be called from both the composefs-boot and ostree-boot code paths. A new TMT plan (plan-44-readonly-unified) onboards a system to unified storage and then runs the full readonly test suite against it. Assisted-by: OpenCode (Claude Sonnet 4.6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 9bcabc1 commit 4f3055d

12 files changed

Lines changed: 1523 additions & 53 deletions

File tree

crates/lib/src/bootc_composefs/export.rs

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,27 @@ use composefs_ctl::composefs;
77
use composefs_ctl::composefs_oci;
88
use composefs_oci::open_config;
99
use ocidir::{OciDir, oci_spec::image::Platform};
10+
use ostree_ext::container::ImageReference;
1011
use ostree_ext::container::Transport;
1112
use ostree_ext::container::skopeo;
1213
use tar::EntryType;
1314

1415
use crate::image::get_imgrefs_for_copy;
1516
use crate::{
16-
bootc_composefs::status::{get_composefs_status, get_imginfo},
17-
store::{BootedComposefs, Storage},
17+
bootc_composefs::status::{ImgConfigManifest, get_composefs_status, get_imginfo},
18+
store::{BootedComposefs, ComposefsRepository, Storage},
1819
};
1920

20-
/// Exports a composefs repository to a container image in containers-storage:
21-
pub async fn export_repo_to_image(
22-
storage: &Storage,
23-
booted_cfs: &BootedComposefs,
24-
source: Option<&str>,
25-
target: Option<&str>,
21+
/// Streams a composefs OCI image out to a destination image reference.
22+
///
23+
/// Given a composefs repository handle and image metadata (manifest + config),
24+
/// reconstructs the container image by reading layer data from the composefs
25+
/// splitstreams and copies the assembled OCI image to `dest_imgref` via skopeo.
26+
pub(crate) async fn export_composefs_to_dest(
27+
composefs_repo: &ComposefsRepository,
28+
imginfo: &ImgConfigManifest,
29+
dest_imgref: &ImageReference,
2630
) -> Result<()> {
27-
let host = get_composefs_status(storage, booted_cfs).await?;
28-
29-
let (source, dest_imgref) = get_imgrefs_for_copy(&host, source, target).await?;
30-
31-
let mut depl_verity = None;
32-
33-
for depl in host.list_deployments() {
34-
let img = &depl.image.as_ref().unwrap().image;
35-
36-
// Not checking transport here as we'll be pulling from the repo anyway
37-
// So, image name is all we need
38-
if img.image == source.name {
39-
depl_verity = Some(depl.require_composefs()?.verity.clone());
40-
break;
41-
}
42-
}
43-
44-
let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?;
45-
46-
let imginfo = get_imginfo(storage, &depl_verity)?;
47-
4831
let config_digest = imginfo.manifest.config().digest().clone();
4932

5033
let var_tmp =
@@ -54,7 +37,7 @@ pub async fn export_repo_to_image(
5437
let oci_dir = OciDir::ensure(tmpdir.try_clone()?).context("Opening OCI")?;
5538

5639
// Use composefs_oci::open_config to get the config and layer map
57-
let open = open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?;
40+
let open = open_config(composefs_repo, &config_digest, None).context("Opening config")?;
5841
let config = open.config;
5942
let layer_map = open.layer_refs;
6043

@@ -77,7 +60,7 @@ pub async fn export_repo_to_image(
7760
.get(old_diff_id.as_str())
7861
.ok_or_else(|| anyhow::anyhow!("Layer {old_diff_id} not found in config"))?;
7962

80-
let mut layer_stream = booted_cfs.repo.open_stream("", Some(layer_verity), None)?;
63+
let mut layer_stream = composefs_repo.open_stream("", Some(layer_verity), None)?;
8164

8265
let mut layer_writer = oci_dir.create_layer(None)?;
8366
layer_writer.follow_symlinks(false);
@@ -113,7 +96,7 @@ pub async fn export_repo_to_image(
11396
match layer_stream.read_exact(size as usize, ((size as usize) + 511) & !511)? {
11497
SplitStreamData::External(obj_id) => match header.entry_type() {
11598
EntryType::Regular | EntryType::Continuous => {
116-
let file = File::from(booted_cfs.repo.open_object(&obj_id)?);
99+
let file = File::from(composefs_repo.open_object(&obj_id)?);
117100

118101
layer_writer
119102
.append(&header, file)
@@ -196,7 +179,7 @@ pub async fn export_repo_to_image(
196179

197180
skopeo::copy(
198181
&tempoci,
199-
&dest_imgref,
182+
dest_imgref,
200183
None,
201184
Some((
202185
std::sync::Arc::new(tmpdir.try_clone()?.into()),
@@ -208,3 +191,37 @@ pub async fn export_repo_to_image(
208191

209192
Ok(())
210193
}
194+
195+
/// Exports a composefs repository to a container image in containers-storage:
196+
pub async fn export_repo_to_image(
197+
storage: &Storage,
198+
booted_cfs: &BootedComposefs,
199+
source: Option<&str>,
200+
target: Option<&str>,
201+
) -> Result<()> {
202+
let host = get_composefs_status(storage, booted_cfs).await?;
203+
204+
let (source, dest_imgref) = get_imgrefs_for_copy(&host, source, target).await?;
205+
206+
let mut depl_verity = None;
207+
208+
for depl in host.list_deployments() {
209+
let img = &depl.image.as_ref().unwrap().image;
210+
211+
// Not checking transport here as we'll be pulling from the repo anyway
212+
// So, image name is all we need
213+
if img.image == source.name {
214+
depl_verity = Some(depl.require_composefs()?.verity.clone());
215+
break;
216+
}
217+
}
218+
219+
let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?;
220+
221+
let imginfo = get_imginfo(storage, &depl_verity)?;
222+
223+
println!("Copying local image {source} to {dest_imgref} ...");
224+
export_composefs_to_dest(&booted_cfs.repo, &imginfo, &dest_imgref).await?;
225+
println!("Pushed: {dest_imgref}");
226+
Ok(())
227+
}

crates/lib/src/image.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,52 @@ pub(crate) async fn push_entrypoint(
235235
let ostree = storage.get_ostree()?;
236236
let repo = &ostree.repo();
237237

238+
// Images pulled via the composefs-unified pipeline are stored in the composefs
239+
// repository with a synthesized ostree commit that has no per-layer blob refs.
240+
// Detect this case and fall through to the composefs export path which reads
241+
// directly from the composefs splitstreams instead of ostree blob refs.
242+
let ostree_ref = ostree_ext::container::store::ref_for_image(&source)?;
243+
if let Some(rev) = repo.resolve_rev(&ostree_ref, true)? {
244+
let (commit_obj, _) = repo.load_commit(rev.as_str())?;
245+
let commit_meta =
246+
ostree_ext::ostree::glib::VariantDict::new(Some(&commit_obj.child_value(0)));
247+
let is_composefs_synthesized = commit_meta
248+
.lookup::<bool>(ostree_ext::container::store::META_COMPOSEFS_SYNTHESIZED)?
249+
.unwrap_or(false);
250+
251+
if is_composefs_synthesized {
252+
// The manifest and config are embedded in the commit metadata.
253+
let manifest_str = commit_meta
254+
.lookup::<String>(ostree_ext::container::store::META_MANIFEST)?
255+
.ok_or_else(|| anyhow::anyhow!("Composefs-synthesized commit missing manifest"))?;
256+
let config_str = commit_meta
257+
.lookup::<String>(ostree_ext::container::store::META_CONFIG)?
258+
.ok_or_else(|| {
259+
anyhow::anyhow!("Composefs-synthesized commit missing image config")
260+
})?;
261+
262+
let manifest: ostree_ext::oci_spec::image::ImageManifest =
263+
serde_json::from_str(&manifest_str)
264+
.context("Deserializing manifest from commit metadata")?;
265+
let config: ostree_ext::oci_spec::image::ImageConfiguration =
266+
serde_json::from_str(&config_str)
267+
.context("Deserializing image config from commit metadata")?;
268+
269+
let imginfo = crate::bootc_composefs::status::ImgConfigManifest { config, manifest };
270+
let composefs_repo = storage.get_ensure_composefs()?;
271+
272+
println!("Copying local image {source} to {target} ...");
273+
crate::bootc_composefs::export::export_composefs_to_dest(
274+
&composefs_repo,
275+
&imginfo,
276+
&target,
277+
)
278+
.await?;
279+
println!("Pushed: {target}");
280+
return Ok(());
281+
}
282+
}
283+
238284
let mut opts = ostree_ext::container::store::ExportToOCIOpts::default();
239285
opts.progress_to_stdout = true;
240286
println!("Copying local image {source} to {target} ...");

crates/lib/src/lsm.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,10 @@ pub(crate) fn ensure_dir_labeled_recurse(
432432
let metadata = component.entry.metadata()?;
433433

434434
// Check if this entry should be skipped
435-
let devino = (metadata.dev() as libc::dev_t, metadata.ino() as libc::ino64_t);
435+
let devino = (
436+
metadata.dev() as libc::dev_t,
437+
metadata.ino() as libc::ino64_t,
438+
);
436439
if skip.contains(&devino) {
437440
tracing::debug!("Skipping dev={} inode={}", devino.0, devino.1);
438441
// For directories, Break skips traversal into the directory

0 commit comments

Comments
 (0)