Skip to content

Commit 01aad3f

Browse files
committed
lib: Add experimental unified storage support for install
Add an experimental --experimental-unified-storage flag to bootc install that uses bootc's container storage (/usr/lib/bootc/storage) to pull images first, then imports from there. This is the same approach used for logically bound images (LBIs). Background: The unified storage approach allows bootc to share container images with podman's storage, reducing disk space and enabling offline installs when images are pre-pulled to the host's container storage. Changes: - Add --experimental-unified-storage CLI flag to install subcommands - Add sysroot_path parameter to prepare_for_pull_unified() and pull_unified() to handle the different mount points during install vs upgrade/switch - Handle localhost images specially by exporting from ostree to container storage first (these can't be pulled from a registry) - Skip pull in prepare_for_pull_unified() if image already exists in bootc storage - Add TMT test for install with unified storage flag - Add TMT test for switching to unified storage on running system - Add integration test for system-reinstall-bootc with unified storage The sysroot_path fix is needed because during install the target disk is mounted at a specific path (e.g., /var/mnt), not /sysroot. Skopeo needs the actual filesystem path to find the bootc storage. Relates: #20 Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Joseph Marrero Corchado <jmarrero@redhat.com>
1 parent 3f5a3c7 commit 01aad3f

File tree

15 files changed

+646
-22
lines changed

15 files changed

+646
-22
lines changed

crates/lib/src/cli.rs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ pub(crate) struct SwitchOpts {
149149
#[clap(long)]
150150
pub(crate) retain: bool,
151151

152+
/// Use unified storage path to pull images (experimental)
153+
///
154+
/// When enabled, this uses bootc's container storage (/usr/lib/bootc/storage) to pull
155+
/// the image first, then imports it from there. This is the same approach used for
156+
/// logically bound images.
157+
#[clap(long = "experimental-unified-storage")]
158+
pub(crate) unified_storage_exp: bool,
159+
152160
/// Target image to use for the next boot.
153161
pub(crate) target: String,
154162

@@ -439,6 +447,11 @@ pub(crate) enum ImageOpts {
439447
/// this will make the image accessible via e.g. `podman run localhost/bootc` and for builds.
440448
target: Option<String>,
441449
},
450+
/// Re-pull the currently booted image into the bootc-owned container storage.
451+
///
452+
/// This onboards the system to the unified storage path so that future
453+
/// upgrade/switch operations can read from the bootc storage directly.
454+
SetUnified,
442455
/// Copy a container image from the default `containers-storage:` to the bootc-owned container storage.
443456
PullFromDefaultStorage {
444457
/// The image to pull
@@ -942,7 +955,15 @@ async fn upgrade(
942955
}
943956
}
944957
} else {
945-
let fetched = crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?;
958+
// Auto-detect whether to use unified storage (no explicit flag for upgrade)
959+
let use_unified = crate::deploy::should_use_unified_storage(storage, imgref, None).await;
960+
961+
let fetched = if use_unified {
962+
crate::deploy::pull_unified(repo, imgref, None, opts.quiet, prog.clone(), storage, None)
963+
.await?
964+
} else {
965+
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?
966+
};
946967
let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
947968
let fetched_digest = &fetched.manifest_digest;
948969
tracing::debug!("staged: {staged_digest:?}");
@@ -1056,7 +1077,21 @@ async fn switch_ostree(
10561077

10571078
let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
10581079

1059-
let fetched = crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?;
1080+
// Determine whether to use unified storage path
1081+
let explicit_flag = if opts.unified_storage_exp {
1082+
Some(true)
1083+
} else {
1084+
None
1085+
};
1086+
let use_unified =
1087+
crate::deploy::should_use_unified_storage(storage, &target, explicit_flag).await;
1088+
1089+
let fetched = if use_unified {
1090+
crate::deploy::pull_unified(repo, &target, None, opts.quiet, prog.clone(), storage, None)
1091+
.await?
1092+
} else {
1093+
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?
1094+
};
10601095

10611096
if !opts.retain {
10621097
// By default, we prune the previous ostree ref so it will go away after later upgrades
@@ -1446,6 +1481,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14461481
ImageOpts::CopyToStorage { source, target } => {
14471482
crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await
14481483
}
1484+
ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
14491485
ImageOpts::PullFromDefaultStorage { image } => {
14501486
let storage = get_storage().await?;
14511487
storage
@@ -1525,7 +1561,10 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15251561
let mut w = SplitStreamWriter::new(&cfs, None, Some(testdata_digest));
15261562
w.write_inline(testdata);
15271563
let object = cfs.write_stream(w, Some("testobject"))?.to_hex();
1528-
assert_eq!(object, "5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07");
1564+
assert_eq!(
1565+
object,
1566+
"5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07"
1567+
);
15291568
Ok(())
15301569
}
15311570
// We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.

crates/lib/src/deploy.rs

Lines changed: 238 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ pub(crate) async fn new_importer(
9393
Ok(imp)
9494
}
9595

96+
/// Wrapper for pulling a container image with a custom proxy config (e.g. for unified storage).
97+
pub(crate) async fn new_importer_with_config(
98+
repo: &ostree::Repo,
99+
imgref: &ostree_container::OstreeImageReference,
100+
config: ostree_ext::containers_image_proxy::ImageProxyConfig,
101+
) -> Result<ostree_container::store::ImageImporter> {
102+
let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?;
103+
imp.require_bootable();
104+
Ok(imp)
105+
}
106+
96107
pub(crate) fn check_bootc_label(config: &ostree_ext::oci_spec::image::ImageConfiguration) {
97108
if let Some(label) =
98109
labels_of_config(config).and_then(|labels| labels.get(crate::metadata::BOOTC_COMPAT_LABEL))
@@ -316,6 +327,16 @@ pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> {
316327
for deployment in deployments {
317328
let bound = crate::boundimage::query_bound_images_for_deployment(ostree, &deployment)?;
318329
all_bound_images.extend(bound.into_iter());
330+
// Also include the host image itself
331+
if let Some(host_image) = crate::status::boot_entry_from_deployment(ostree, &deployment)?
332+
.image
333+
.map(|i| i.image)
334+
{
335+
all_bound_images.push(crate::boundimage::BoundImage {
336+
image: crate::utils::imageref_to_container_ref(&host_image),
337+
auth_file: None,
338+
});
339+
}
319340
}
320341
// Convert to a hashset of just the image names
321342
let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str()));
@@ -381,6 +402,205 @@ pub(crate) async fn prepare_for_pull(
381402
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
382403
}
383404

405+
/// Check whether to use the unified storage path for pulling an image.
406+
///
407+
/// If `explicit_flag` is Some(true), unified storage is always used.
408+
/// If `explicit_flag` is Some(false), unified storage is never used.
409+
/// If `explicit_flag` is None, auto-detect based on whether the image already exists
410+
/// in bootc's container storage.
411+
///
412+
/// Returns true if unified storage should be used.
413+
pub(crate) async fn should_use_unified_storage(
414+
store: &Storage,
415+
imgref: &ImageReference,
416+
explicit_flag: Option<bool>,
417+
) -> bool {
418+
// Explicit flag takes precedence
419+
if let Some(flag) = explicit_flag {
420+
return flag;
421+
}
422+
423+
// Auto-detect: check if image exists in bootc storage
424+
let imgstore = match store.get_ensure_imgstore() {
425+
std::result::Result::Ok(s) => s,
426+
std::result::Result::Err(e) => {
427+
tracing::warn!("Failed to access bootc storage: {e}; falling back to standard pull");
428+
return false;
429+
}
430+
};
431+
432+
let image_ref_str = crate::utils::imageref_to_container_ref(imgref);
433+
match imgstore.exists(&image_ref_str).await {
434+
std::result::Result::Ok(v) => v,
435+
std::result::Result::Err(e) => {
436+
tracing::warn!(
437+
"Failed to check bootc storage for image: {e}; falling back to standard pull"
438+
);
439+
false
440+
}
441+
}
442+
}
443+
444+
/// Unified approach: Use bootc's CStorage to pull the image, then prepare from containers-storage.
445+
/// This reuses the same infrastructure as LBIs.
446+
///
447+
/// The `sysroot_path` parameter specifies the path to the sysroot where bootc storage is located.
448+
/// During install, this should be the path to the target disk's mount point.
449+
/// During upgrade/switch on a running system, pass `None` to use the default `/sysroot`.
450+
pub(crate) async fn prepare_for_pull_unified(
451+
repo: &ostree::Repo,
452+
imgref: &ImageReference,
453+
target_imgref: Option<&OstreeImageReference>,
454+
store: &Storage,
455+
sysroot_path: Option<&camino::Utf8Path>,
456+
) -> Result<PreparedPullResult> {
457+
// Get or initialize the bootc container storage (same as used for LBIs)
458+
let imgstore = store.get_ensure_imgstore()?;
459+
460+
let image_ref_str = crate::utils::imageref_to_container_ref(imgref);
461+
462+
// Check if image already exists in bootc storage - if so, skip the pull
463+
// This is important for localhost images which can't be pulled from a registry
464+
let image_exists = match imgstore.exists(&image_ref_str).await {
465+
std::result::Result::Ok(v) => v,
466+
std::result::Result::Err(e) => {
467+
tracing::warn!("Failed to check bootc storage for image: {e}; will attempt pull");
468+
false
469+
}
470+
};
471+
472+
if image_exists {
473+
tracing::info!(
474+
"Unified pull: image '{}' already exists in bootc storage, skipping pull",
475+
&image_ref_str
476+
);
477+
} else {
478+
// Log the original transport being used for the pull
479+
tracing::info!(
480+
"Unified pull: pulling from transport '{}' to bootc storage",
481+
&imgref.transport
482+
);
483+
484+
// Pull the image to bootc storage using the same method as LBIs
485+
// Show a spinner since podman pull can take a while and doesn't output progress
486+
let pull_msg = format!("Pulling {} to bootc storage", &image_ref_str);
487+
async_task_with_spinner(&pull_msg, async move {
488+
imgstore
489+
.pull(&image_ref_str, crate::podstorage::PullMode::Always)
490+
.await
491+
})
492+
.await?;
493+
}
494+
495+
// Now create a containers-storage reference to read from bootc storage
496+
tracing::info!("Unified pull: now importing from containers-storage transport");
497+
let containers_storage_imgref = ImageReference {
498+
transport: "containers-storage".to_string(),
499+
image: imgref.image.clone(),
500+
signature: imgref.signature.clone(),
501+
};
502+
let ostree_imgref = OstreeImageReference::from(containers_storage_imgref);
503+
504+
// Configure the importer to use bootc storage as an additional image store
505+
use std::process::Command;
506+
let mut config = ostree_ext::containers_image_proxy::ImageProxyConfig::default();
507+
let mut cmd = Command::new("skopeo");
508+
// Use the actual physical path to bootc storage
509+
// During install, this is the target disk's mount point; otherwise default to /sysroot
510+
let sysroot_base = sysroot_path
511+
.map(|p| p.to_string())
512+
.unwrap_or_else(|| "/sysroot".to_string());
513+
let storage_path = format!(
514+
"{}/{}",
515+
sysroot_base,
516+
crate::podstorage::CStorage::subpath()
517+
);
518+
crate::podstorage::set_additional_image_store(&mut cmd, &storage_path);
519+
config.skopeo_cmd = Some(cmd);
520+
521+
// Use the preparation flow with the custom config
522+
let mut imp = new_importer_with_config(repo, &ostree_imgref, config).await?;
523+
if let Some(target) = target_imgref {
524+
imp.set_target(target);
525+
}
526+
let prep = match imp.prepare().await? {
527+
PrepareResult::AlreadyPresent(c) => {
528+
println!("No changes in {imgref:#} => {}", c.manifest_digest);
529+
return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
530+
}
531+
PrepareResult::Ready(p) => p,
532+
};
533+
check_bootc_label(&prep.config);
534+
if let Some(warning) = prep.deprecated_warning() {
535+
ostree_ext::cli::print_deprecated_warning(warning).await;
536+
}
537+
ostree_ext::cli::print_layer_status(&prep);
538+
let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
539+
540+
// Log that we're importing a new image from containers-storage
541+
const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
542+
tracing::info!(
543+
message_id = PULLING_NEW_IMAGE_ID,
544+
bootc.image.reference = &imgref.image,
545+
bootc.image.transport = "containers-storage",
546+
bootc.original_transport = &imgref.transport,
547+
bootc.status = "importing_from_storage",
548+
"Importing image from bootc storage: {}",
549+
ostree_imgref
550+
);
551+
552+
let prepared_image = PreparedImportMeta {
553+
imp,
554+
n_layers_to_fetch: layers_to_fetch.len(),
555+
layers_total: prep.all_layers().count(),
556+
bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
557+
bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
558+
digest: prep.manifest_digest.clone(),
559+
prep,
560+
};
561+
562+
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
563+
}
564+
565+
/// Unified pull: Use podman to pull to containers-storage, then read from there
566+
///
567+
/// The `sysroot_path` parameter specifies the path to the sysroot where bootc storage is located.
568+
/// For normal upgrade/switch operations, pass `None` to use the default `/sysroot`.
569+
pub(crate) async fn pull_unified(
570+
repo: &ostree::Repo,
571+
imgref: &ImageReference,
572+
target_imgref: Option<&OstreeImageReference>,
573+
quiet: bool,
574+
prog: ProgressWriter,
575+
store: &Storage,
576+
sysroot_path: Option<&camino::Utf8Path>,
577+
) -> Result<Box<ImageState>> {
578+
match prepare_for_pull_unified(repo, imgref, target_imgref, store, sysroot_path).await? {
579+
PreparedPullResult::AlreadyPresent(existing) => {
580+
// Log that the image was already present (Debug level since it's not actionable)
581+
const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
582+
tracing::debug!(
583+
message_id = IMAGE_ALREADY_PRESENT_ID,
584+
bootc.image.reference = &imgref.image,
585+
bootc.image.transport = &imgref.transport,
586+
bootc.status = "already_present",
587+
"Image already present: {}",
588+
imgref
589+
);
590+
Ok(existing)
591+
}
592+
PreparedPullResult::Ready(prepared_image_meta) => {
593+
// To avoid duplicate success logs, pass a containers-storage imgref to the importer
594+
let cs_imgref = ImageReference {
595+
transport: "containers-storage".to_string(),
596+
image: imgref.image.clone(),
597+
signature: imgref.signature.clone(),
598+
};
599+
pull_from_prepared(&cs_imgref, quiet, prog, *prepared_image_meta).await
600+
}
601+
}
602+
}
603+
384604
#[context("Pulling")]
385605
pub(crate) async fn pull_from_prepared(
386606
imgref: &ImageReference,
@@ -430,18 +650,21 @@ pub(crate) async fn pull_from_prepared(
430650
let imgref_canonicalized = imgref.clone().canonicalize()?;
431651
tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
432652

433-
// Log successful import completion
434-
const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8";
435-
436-
tracing::info!(
437-
message_id = IMPORT_COMPLETE_JOURNAL_ID,
438-
bootc.image.reference = &imgref.image,
439-
bootc.image.transport = &imgref.transport,
440-
bootc.manifest_digest = import.manifest_digest.as_ref(),
441-
bootc.ostree_commit = &import.merge_commit,
442-
"Successfully imported image: {}",
443-
imgref
444-
);
653+
// Log successful import completion (skip if using unified storage to avoid double logging)
654+
let is_unified_path = imgref.transport == "containers-storage";
655+
if !is_unified_path {
656+
const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8";
657+
658+
tracing::info!(
659+
message_id = IMPORT_COMPLETE_JOURNAL_ID,
660+
bootc.image.reference = &imgref.image,
661+
bootc.image.transport = &imgref.transport,
662+
bootc.manifest_digest = import.manifest_digest.as_ref(),
663+
bootc.ostree_commit = &import.merge_commit,
664+
"Successfully imported image: {}",
665+
imgref
666+
);
667+
}
445668

446669
if let Some(msg) =
447670
ostree_container::store::image_filtered_content_warning(&import.filtered_files)
@@ -490,6 +713,9 @@ pub(crate) async fn pull(
490713
}
491714
}
492715

716+
/// Pull selecting unified vs standard path based on persistent storage config.
717+
// pull_auto was reverted per request; keep explicit callers branching.
718+
493719
pub(crate) async fn wipe_ostree(sysroot: Sysroot) -> Result<()> {
494720
tokio::task::spawn_blocking(move || {
495721
sysroot

0 commit comments

Comments
 (0)