Skip to content

Commit ddbba51

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 ddbba51

File tree

18 files changed

+770
-55
lines changed

18 files changed

+770
-55
lines changed

crates/lib/src/cli.rs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::os::unix::process::CommandExt;
99
use std::process::Command;
1010
use std::sync::Arc;
1111

12-
use anyhow::{anyhow, ensure, Context, Result};
12+
use anyhow::{Context, Result, anyhow, ensure};
1313
use camino::{Utf8Path, Utf8PathBuf};
1414
use cap_std_ext::cap_std;
1515
use cap_std_ext::cap_std::fs::Dir;
@@ -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", hide = true)]
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 based on image presence in bootc storage
965+
let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).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+
// If explicitly requested via flag, use unified storage directly.
1088+
// Otherwise, auto-detect based on whether the image exists in bootc storage.
1089+
let use_unified = if opts.unified_storage_exp {
1090+
true
1091+
} else {
1092+
crate::deploy::image_exists_in_unified_storage(storage, &target).await
1093+
};
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
@@ -1422,7 +1457,9 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14221457
tracing::debug!("Computing digest of {iid}");
14231458

14241459
if !host_container_store.try_exists()? {
1425-
anyhow::bail!("Must be readonly mount of host container store: {host_container_store}");
1460+
anyhow::bail!(
1461+
"Must be readonly mount of host container store: {host_container_store}"
1462+
);
14261463
}
14271464
// And ensure we're finding the image in the host storage
14281465
let mut cmd = Command::new("skopeo");
@@ -1460,6 +1497,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14601497
ImageOpts::CopyToStorage { source, target } => {
14611498
crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await
14621499
}
1500+
ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
14631501
ImageOpts::PullFromDefaultStorage { image } => {
14641502
let storage = get_storage().await?;
14651503
storage
@@ -1539,7 +1577,10 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15391577
let mut w = SplitStreamWriter::new(&cfs, None, Some(testdata_digest));
15401578
w.write_inline(testdata);
15411579
let object = cfs.write_stream(w, Some("testobject"))?.to_hex();
1542-
assert_eq!(object, "5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07");
1580+
assert_eq!(
1581+
object,
1582+
"5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07"
1583+
);
15431584
Ok(())
15441585
}
15451586
// We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.

crates/lib/src/deploy.rs

Lines changed: 215 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
55
use std::collections::HashSet;
66
use std::io::{BufRead, Write};
7+
use std::process::Command;
78

8-
use anyhow::Ok;
9-
use anyhow::{anyhow, Context, Result};
9+
use anyhow::{Context, Result, anyhow};
1010
use bootc_kernel_cmdline::utf8::CmdlineOwned;
1111
use cap_std::fs::{Dir, MetadataExt};
1212
use cap_std_ext::cap_std;
@@ -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,180 @@ pub(crate) async fn prepare_for_pull(
381404
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
382405
}
383406

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

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

446646
if let Some(msg) =
447647
ostree_container::store::image_filtered_content_warning(&import.filtered_files)
@@ -1051,7 +1251,7 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> {
10511251
}
10521252

10531253
// Read the input, and atomically write a modified version
1054-
root.atomic_replace_with(fstab_path, move |mut w| {
1254+
root.atomic_replace_with(fstab_path, move |mut w| -> Result<()> {
10551255
for line in fd.lines() {
10561256
let line = line?;
10571257
if !edit_fstab_line(&line, &mut w)? {

0 commit comments

Comments
 (0)