Skip to content

Commit 68b1ba6

Browse files
committed
lib: Add unified-storage experimental feature
Add an experimental --unified-storage flag to bootc install and system-reinstall-bootc that configures the system to use a single container-storage instance for both host and bootc images instead of separate stores. This includes: - New CLI flag --unified-storage for install and system-reinstall - Logic to configure container-storage for unified mode - Support in deploy and image modules for unified storage - Integration tests for install and switch operations - TMT test plans for unified storage scenarios Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Joseph Marrero Corchado <[email protected]>
1 parent 3ad82d0 commit 68b1ba6

File tree

15 files changed

+485
-21
lines changed

15 files changed

+485
-21
lines changed

crates/lib/src/cli.rs

Lines changed: 63 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,27 @@ async fn upgrade(
942955
}
943956
}
944957
} else {
945-
let fetched = crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?;
958+
// Check if image exists in bootc storage (/usr/lib/bootc/storage)
959+
let imgstore = storage.get_ensure_imgstore()?;
960+
961+
let image_ref_str = crate::utils::imageref_to_container_ref(imgref);
962+
963+
let use_unified = match imgstore.exists(&image_ref_str).await {
964+
Ok(v) => v,
965+
Err(e) => {
966+
tracing::warn!(
967+
"Failed to check bootc storage for image: {e}; falling back to standard pull"
968+
);
969+
false
970+
}
971+
};
972+
973+
let fetched = if use_unified {
974+
crate::deploy::pull_unified(repo, imgref, None, opts.quiet, prog.clone(), storage)
975+
.await?
976+
} else {
977+
crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?
978+
};
946979
let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
947980
let fetched_digest = &fetched.manifest_digest;
948981
tracing::debug!("staged: {staged_digest:?}");
@@ -1056,7 +1089,30 @@ async fn switch_ostree(
10561089

10571090
let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
10581091

1059-
let fetched = crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?;
1092+
// Determine whether to use unified storage path
1093+
let use_unified = if opts.unified_storage_exp {
1094+
// Explicit flag always uses unified path
1095+
true
1096+
} else {
1097+
// Auto-detect: check if image exists in bootc storage
1098+
let imgstore = storage.get_ensure_imgstore()?;
1099+
let target_ref_str = crate::utils::imageref_to_container_ref(&target);
1100+
match imgstore.exists(&target_ref_str).await {
1101+
Ok(v) => v,
1102+
Err(e) => {
1103+
tracing::warn!(
1104+
"Failed to check bootc storage for image: {e}; falling back to standard pull"
1105+
);
1106+
false
1107+
}
1108+
}
1109+
};
1110+
1111+
let fetched = if use_unified {
1112+
crate::deploy::pull_unified(repo, &target, None, opts.quiet, prog.clone(), storage).await?
1113+
} else {
1114+
crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?
1115+
};
10601116

10611117
if !opts.retain {
10621118
// By default, we prune the previous ostree ref so it will go away after later upgrades
@@ -1446,6 +1502,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14461502
ImageOpts::CopyToStorage { source, target } => {
14471503
crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await
14481504
}
1505+
ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
14491506
ImageOpts::PullFromDefaultStorage { image } => {
14501507
let storage = get_storage().await?;
14511508
storage
@@ -1525,7 +1582,10 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15251582
let mut w = SplitStreamWriter::new(&cfs, None, Some(testdata_digest));
15261583
w.write_inline(testdata);
15271584
let object = cfs.write_stream(w, Some("testobject"))?.to_hex();
1528-
assert_eq!(object, "5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07");
1585+
assert_eq!(
1586+
object,
1587+
"5d94ceb0b2bb3a78237e0a74bc030a262239ab5f47754a5eb2e42941056b64cb21035d64a8f7c2f156e34b820802fa51884de2b1f7dc3a41b9878fc543cd9b07"
1588+
);
15291589
Ok(())
15301590
}
15311591
// We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.

crates/lib/src/deploy.rs

Lines changed: 160 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,127 @@ pub(crate) async fn prepare_for_pull(
381402
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
382403
}
383404

405+
/// Unified approach: Use bootc's CStorage to pull the image, then prepare from containers-storage.
406+
/// This reuses the same infrastructure as LBIs.
407+
pub(crate) async fn prepare_for_pull_unified(
408+
repo: &ostree::Repo,
409+
imgref: &ImageReference,
410+
target_imgref: Option<&OstreeImageReference>,
411+
store: &Storage,
412+
) -> Result<PreparedPullResult> {
413+
// Get or initialize the bootc container storage (same as used for LBIs)
414+
let imgstore = store.get_ensure_imgstore()?;
415+
416+
let image_ref_str = crate::utils::imageref_to_container_ref(imgref);
417+
418+
// Log the original transport being used for the pull
419+
tracing::info!(
420+
"Unified pull: pulling from transport '{}' to bootc storage",
421+
&imgref.transport
422+
);
423+
424+
// Pull the image to bootc storage using the same method as LBIs
425+
imgstore
426+
.pull(&image_ref_str, crate::podstorage::PullMode::Always)
427+
.await?;
428+
429+
// Now create a containers-storage reference to read from bootc storage
430+
tracing::info!("Unified pull: now importing from containers-storage transport");
431+
let containers_storage_imgref = ImageReference {
432+
transport: "containers-storage".to_string(),
433+
image: imgref.image.clone(),
434+
signature: imgref.signature.clone(),
435+
};
436+
let ostree_imgref = OstreeImageReference::from(containers_storage_imgref);
437+
438+
// Configure the importer to use bootc storage as an additional image store
439+
use std::process::Command;
440+
let mut config = ostree_ext::containers_image_proxy::ImageProxyConfig::default();
441+
let mut cmd = Command::new("skopeo");
442+
// Use the actual physical path to bootc storage, not the alias
443+
let storage_path = format!("/sysroot/{}", crate::podstorage::CStorage::subpath());
444+
crate::podstorage::set_additional_image_store(&mut cmd, &storage_path);
445+
config.skopeo_cmd = Some(cmd);
446+
447+
// Use the preparation flow with the custom config
448+
let mut imp = new_importer_with_config(repo, &ostree_imgref, config).await?;
449+
if let Some(target) = target_imgref {
450+
imp.set_target(target);
451+
}
452+
let prep = match imp.prepare().await? {
453+
PrepareResult::AlreadyPresent(c) => {
454+
println!("No changes in {imgref:#} => {}", c.manifest_digest);
455+
return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
456+
}
457+
PrepareResult::Ready(p) => p,
458+
};
459+
check_bootc_label(&prep.config);
460+
if let Some(warning) = prep.deprecated_warning() {
461+
ostree_ext::cli::print_deprecated_warning(warning).await;
462+
}
463+
ostree_ext::cli::print_layer_status(&prep);
464+
let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
465+
466+
// Log that we're importing a new image from containers-storage
467+
const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
468+
tracing::info!(
469+
message_id = PULLING_NEW_IMAGE_ID,
470+
bootc.image.reference = &imgref.image,
471+
bootc.image.transport = "containers-storage",
472+
bootc.original_transport = &imgref.transport,
473+
bootc.status = "importing_from_storage",
474+
"Importing image from bootc storage: {}",
475+
ostree_imgref
476+
);
477+
478+
let prepared_image = PreparedImportMeta {
479+
imp,
480+
n_layers_to_fetch: layers_to_fetch.len(),
481+
layers_total: prep.all_layers().count(),
482+
bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
483+
bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
484+
digest: prep.manifest_digest.clone(),
485+
prep,
486+
};
487+
488+
Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
489+
}
490+
491+
/// Unified pull: Use podman to pull to containers-storage, then read from there
492+
pub(crate) async fn pull_unified(
493+
repo: &ostree::Repo,
494+
imgref: &ImageReference,
495+
target_imgref: Option<&OstreeImageReference>,
496+
quiet: bool,
497+
prog: ProgressWriter,
498+
store: &Storage,
499+
) -> Result<Box<ImageState>> {
500+
match prepare_for_pull_unified(repo, imgref, target_imgref, store).await? {
501+
PreparedPullResult::AlreadyPresent(existing) => {
502+
// Log that the image was already present (Debug level since it's not actionable)
503+
const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
504+
tracing::debug!(
505+
message_id = IMAGE_ALREADY_PRESENT_ID,
506+
bootc.image.reference = &imgref.image,
507+
bootc.image.transport = &imgref.transport,
508+
bootc.status = "already_present",
509+
"Image already present: {}",
510+
imgref
511+
);
512+
Ok(existing)
513+
}
514+
PreparedPullResult::Ready(prepared_image_meta) => {
515+
// To avoid duplicate success logs, pass a containers-storage imgref to the importer
516+
let cs_imgref = ImageReference {
517+
transport: "containers-storage".to_string(),
518+
image: imgref.image.clone(),
519+
signature: imgref.signature.clone(),
520+
};
521+
pull_from_prepared(&cs_imgref, quiet, prog, *prepared_image_meta).await
522+
}
523+
}
524+
}
525+
384526
#[context("Pulling")]
385527
pub(crate) async fn pull_from_prepared(
386528
imgref: &ImageReference,
@@ -430,18 +572,21 @@ pub(crate) async fn pull_from_prepared(
430572
let imgref_canonicalized = imgref.clone().canonicalize()?;
431573
tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
432574

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-
);
575+
// Log successful import completion (skip if using unified storage to avoid double logging)
576+
let is_unified_path = imgref.transport == "containers-storage";
577+
if !is_unified_path {
578+
const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8";
579+
580+
tracing::info!(
581+
message_id = IMPORT_COMPLETE_JOURNAL_ID,
582+
bootc.image.reference = &imgref.image,
583+
bootc.image.transport = &imgref.transport,
584+
bootc.manifest_digest = import.manifest_digest.as_ref(),
585+
bootc.ostree_commit = &import.merge_commit,
586+
"Successfully imported image: {}",
587+
imgref
588+
);
589+
}
445590

446591
if let Some(msg) =
447592
ostree_container::store::image_filtered_content_warning(&import.filtered_files)
@@ -490,6 +635,9 @@ pub(crate) async fn pull(
490635
}
491636
}
492637

638+
/// Pull selecting unified vs standard path based on persistent storage config.
639+
// pull_auto was reverted per request; keep explicit callers branching.
640+
493641
pub(crate) async fn wipe_ostree(sysroot: Sysroot) -> Result<()> {
494642
tokio::task::spawn_blocking(move || {
495643
sysroot

crates/lib/src/image.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,63 @@ pub(crate) async fn imgcmd_entrypoint(
181181
cmd.args(args);
182182
cmd.run_capture_stderr()
183183
}
184+
185+
/// Re-pull the currently booted image into the bootc-owned container storage.
186+
///
187+
/// This onboards the system to unified storage for host images so that
188+
/// upgrade/switch can use the unified path automatically when the image is present.
189+
#[context("Setting unified storage for booted image")]
190+
pub(crate) async fn set_unified_entrypoint() -> Result<()> {
191+
let sysroot = crate::cli::get_storage().await?;
192+
let ostree = sysroot.get_ostree()?;
193+
let repo = &ostree.repo();
194+
195+
// Discover the currently booted image reference
196+
let (_booted_deployment, _deployments, host) =
197+
crate::status::get_status_require_booted(ostree)?;
198+
let imgref = host
199+
.spec
200+
.image
201+
.as_ref()
202+
.ok_or_else(|| anyhow::anyhow!("No image source specified in host spec"))?;
203+
204+
// Canonicalize for pull display only, but we want to preserve original pullspec
205+
let imgref_display = imgref.clone().canonicalize()?;
206+
207+
// Pull the image from its original source into bootc storage using LBI machinery
208+
let imgstore = sysroot.get_ensure_imgstore()?;
209+
210+
let img_string = crate::utils::imageref_to_container_ref(imgref);
211+
212+
const SET_UNIFIED_JOURNAL_ID: &str = "1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d";
213+
tracing::info!(
214+
message_id = SET_UNIFIED_JOURNAL_ID,
215+
bootc.image.reference = &imgref_display.image,
216+
bootc.image.transport = &imgref_display.transport,
217+
"Re-pulling booted image into bootc storage via unified path: {}",
218+
imgref_display
219+
);
220+
imgstore
221+
.pull(&img_string, crate::podstorage::PullMode::Always)
222+
.await?;
223+
224+
// Optionally verify we can import from containers-storage by preparing in a temp importer
225+
// without actually importing into the main repo; this is a lightweight validation.
226+
let containers_storage_imgref = crate::spec::ImageReference {
227+
transport: "containers-storage".to_string(),
228+
image: imgref.image.clone(),
229+
signature: imgref.signature.clone(),
230+
};
231+
let ostree_imgref =
232+
ostree_ext::container::OstreeImageReference::from(containers_storage_imgref);
233+
let _ =
234+
ostree_ext::container::store::ImageImporter::new(repo, &ostree_imgref, Default::default())
235+
.await?;
236+
237+
tracing::info!(
238+
message_id = SET_UNIFIED_JOURNAL_ID,
239+
bootc.status = "set_unified_complete",
240+
"Unified storage set for current image. Future upgrade/switch will use it automatically."
241+
);
242+
Ok(())
243+
}

0 commit comments

Comments
 (0)