Skip to content

Commit 04620ac

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 04620ac

File tree

15 files changed

+681
-22
lines changed

15 files changed

+681
-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: 221 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,18 @@ 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+
// Note: Use just the image name (not the full transport:image format) because
332+
// podman's image names don't include the transport prefix.
333+
if let Some(host_image) = crate::status::boot_entry_from_deployment(ostree, &deployment)?
334+
.image
335+
.map(|i| i.image)
336+
{
337+
all_bound_images.push(crate::boundimage::BoundImage {
338+
image: host_image.image.clone(),
339+
auth_file: None,
340+
});
341+
}
319342
}
320343
// Convert to a hashset of just the image names
321344
let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str()));
@@ -381,6 +404,189 @@ pub(crate) async fn prepare_for_pull(
381404
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
382405
}
383406

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

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

446655
if let Some(msg) =
447656
ostree_container::store::image_filtered_content_warning(&import.filtered_files)

0 commit comments

Comments
 (0)