Skip to content

Commit bb9883e

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 <[email protected]>
1 parent 8c0c2f6 commit bb9883e

File tree

15 files changed

+623
-22
lines changed

15 files changed

+623
-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

@@ -445,6 +453,11 @@ pub(crate) enum ImageOpts {
445453
/// this will make the image accessible via e.g. `podman run localhost/bootc` and for builds.
446454
target: Option<String>,
447455
},
456+
/// Re-pull the currently booted image into the bootc-owned container storage.
457+
///
458+
/// This onboards the system to the unified storage path so that future
459+
/// upgrade/switch operations can read from the bootc storage directly.
460+
SetUnified,
448461
/// Copy a container image from the default `containers-storage:` to the bootc-owned container storage.
449462
PullFromDefaultStorage {
450463
/// The image to pull
@@ -948,7 +961,15 @@ async fn upgrade(
948961
}
949962
}
950963
} else {
951-
let fetched = crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?;
964+
// Auto-detect whether to use unified storage (no explicit flag for upgrade)
965+
let use_unified = crate::deploy::should_use_unified_storage(storage, imgref, None).await;
966+
967+
let fetched = if use_unified {
968+
crate::deploy::pull_unified(repo, imgref, None, opts.quiet, prog.clone(), storage, None)
969+
.await?
970+
} else {
971+
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?
972+
};
952973
let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
953974
let fetched_digest = &fetched.manifest_digest;
954975
tracing::debug!("staged: {staged_digest:?}");
@@ -1062,7 +1083,21 @@ async fn switch_ostree(
10621083

10631084
let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
10641085

1065-
let fetched = crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?;
1086+
// Determine whether to use unified storage path
1087+
let explicit_flag = if opts.unified_storage_exp {
1088+
Some(true)
1089+
} else {
1090+
None
1091+
};
1092+
let use_unified =
1093+
crate::deploy::should_use_unified_storage(storage, &target, explicit_flag).await;
1094+
1095+
let fetched = if use_unified {
1096+
crate::deploy::pull_unified(repo, &target, None, opts.quiet, prog.clone(), storage, None)
1097+
.await?
1098+
} else {
1099+
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?
1100+
};
10661101

10671102
if !opts.retain {
10681103
// By default, we prune the previous ostree ref so it will go away after later upgrades
@@ -1460,6 +1495,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14601495
ImageOpts::CopyToStorage { source, target } => {
14611496
crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await
14621497
}
1498+
ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
14631499
ImageOpts::PullFromDefaultStorage { image } => {
14641500
let storage = get_storage().await?;
14651501
storage
@@ -1539,7 +1575,10 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15391575
let mut w = SplitStreamWriter::new(&cfs, None, Some(testdata_digest));
15401576
w.write_inline(testdata);
15411577
let object = cfs.write_stream(w, Some("testobject"))?.to_hex();
1542-
assert_eq!(object, "5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07");
1578+
assert_eq!(
1579+
object,
1580+
"5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07"
1581+
);
15431582
Ok(())
15441583
}
15451584
// We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.

crates/lib/src/deploy.rs

Lines changed: 219 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,189 @@ 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+
// Always pull to ensure we have the latest image, whether from a remote
463+
// registry or a locally rebuilt image
464+
tracing::info!(
465+
"Unified pull: pulling from transport '{}' to bootc storage",
466+
&imgref.transport
467+
);
468+
469+
// Pull the image to bootc storage using the same method as LBIs
470+
// Show a spinner since podman pull can take a while and doesn't output progress
471+
let pull_msg = format!("Pulling {} to bootc storage", &image_ref_str);
472+
async_task_with_spinner(&pull_msg, async move {
473+
imgstore
474+
.pull(&image_ref_str, crate::podstorage::PullMode::Always)
475+
.await
476+
})
477+
.await?;
478+
479+
// Now create a containers-storage reference to read from bootc storage
480+
tracing::info!("Unified pull: now importing from containers-storage transport");
481+
let containers_storage_imgref = ImageReference {
482+
transport: "containers-storage".to_string(),
483+
image: imgref.image.clone(),
484+
signature: imgref.signature.clone(),
485+
};
486+
let ostree_imgref = OstreeImageReference::from(containers_storage_imgref);
487+
488+
// Configure the importer to use bootc storage as an additional image store
489+
use std::process::Command;
490+
let mut config = ostree_ext::containers_image_proxy::ImageProxyConfig::default();
491+
let mut cmd = Command::new("skopeo");
492+
// Use the actual physical path to bootc storage
493+
// During install, this is the target disk's mount point; otherwise default to /sysroot
494+
let sysroot_base = sysroot_path
495+
.map(|p| p.to_string())
496+
.unwrap_or_else(|| "/sysroot".to_string());
497+
let storage_path = format!(
498+
"{}/{}",
499+
sysroot_base,
500+
crate::podstorage::CStorage::subpath()
501+
);
502+
crate::podstorage::set_additional_image_store(&mut cmd, &storage_path);
503+
config.skopeo_cmd = Some(cmd);
504+
505+
// Use the preparation flow with the custom config
506+
let mut imp = new_importer_with_config(repo, &ostree_imgref, config).await?;
507+
if let Some(target) = target_imgref {
508+
imp.set_target(target);
509+
}
510+
let prep = match imp.prepare().await? {
511+
PrepareResult::AlreadyPresent(c) => {
512+
println!("No changes in {imgref:#} => {}", c.manifest_digest);
513+
return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
514+
}
515+
PrepareResult::Ready(p) => p,
516+
};
517+
check_bootc_label(&prep.config);
518+
if let Some(warning) = prep.deprecated_warning() {
519+
ostree_ext::cli::print_deprecated_warning(warning).await;
520+
}
521+
ostree_ext::cli::print_layer_status(&prep);
522+
let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
523+
524+
// Log that we're importing a new image from containers-storage
525+
const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
526+
tracing::info!(
527+
message_id = PULLING_NEW_IMAGE_ID,
528+
bootc.image.reference = &imgref.image,
529+
bootc.image.transport = "containers-storage",
530+
bootc.original_transport = &imgref.transport,
531+
bootc.status = "importing_from_storage",
532+
"Importing image from bootc storage: {}",
533+
ostree_imgref
534+
);
535+
536+
let prepared_image = PreparedImportMeta {
537+
imp,
538+
n_layers_to_fetch: layers_to_fetch.len(),
539+
layers_total: prep.all_layers().count(),
540+
bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
541+
bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
542+
digest: prep.manifest_digest.clone(),
543+
prep,
544+
};
545+
546+
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
547+
}
548+
549+
/// Unified pull: Use podman to pull to containers-storage, then read from there
550+
///
551+
/// The `sysroot_path` parameter specifies the path to the sysroot where bootc storage is located.
552+
/// For normal upgrade/switch operations, pass `None` to use the default `/sysroot`.
553+
pub(crate) async fn pull_unified(
554+
repo: &ostree::Repo,
555+
imgref: &ImageReference,
556+
target_imgref: Option<&OstreeImageReference>,
557+
quiet: bool,
558+
prog: ProgressWriter,
559+
store: &Storage,
560+
sysroot_path: Option<&camino::Utf8Path>,
561+
) -> Result<Box<ImageState>> {
562+
match prepare_for_pull_unified(repo, imgref, target_imgref, store, sysroot_path).await? {
563+
PreparedPullResult::AlreadyPresent(existing) => {
564+
// Log that the image was already present (Debug level since it's not actionable)
565+
const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
566+
tracing::debug!(
567+
message_id = IMAGE_ALREADY_PRESENT_ID,
568+
bootc.image.reference = &imgref.image,
569+
bootc.image.transport = &imgref.transport,
570+
bootc.status = "already_present",
571+
"Image already present: {}",
572+
imgref
573+
);
574+
Ok(existing)
575+
}
576+
PreparedPullResult::Ready(prepared_image_meta) => {
577+
// To avoid duplicate success logs, pass a containers-storage imgref to the importer
578+
let cs_imgref = ImageReference {
579+
transport: "containers-storage".to_string(),
580+
image: imgref.image.clone(),
581+
signature: imgref.signature.clone(),
582+
};
583+
pull_from_prepared(&cs_imgref, quiet, prog, *prepared_image_meta).await
584+
}
585+
}
586+
}
587+
384588
#[context("Pulling")]
385589
pub(crate) async fn pull_from_prepared(
386590
imgref: &ImageReference,
@@ -430,18 +634,21 @@ pub(crate) async fn pull_from_prepared(
430634
let imgref_canonicalized = imgref.clone().canonicalize()?;
431635
tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
432636

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-
);
637+
// Log successful import completion (skip if using unified storage to avoid double logging)
638+
let is_unified_path = imgref.transport == "containers-storage";
639+
if !is_unified_path {
640+
const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8";
641+
642+
tracing::info!(
643+
message_id = IMPORT_COMPLETE_JOURNAL_ID,
644+
bootc.image.reference = &imgref.image,
645+
bootc.image.transport = &imgref.transport,
646+
bootc.manifest_digest = import.manifest_digest.as_ref(),
647+
bootc.ostree_commit = &import.merge_commit,
648+
"Successfully imported image: {}",
649+
imgref
650+
);
651+
}
445652

446653
if let Some(msg) =
447654
ostree_container::store::image_filtered_content_warning(&import.filtered_files)

0 commit comments

Comments
 (0)