From b2d8833fdc4397cf7e80fb104abd8268b9eb4351 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 1 Oct 2026 14:21:08 +0530 Subject: [PATCH 01/10] switch: Add a target reference for future updates Allow switch to accept an update origin separate from the image fetched now. Pass the target through OSTree staging and composefs switch so later updates can follow the recorded reference. Signed-off-by: Pragyan Poudyal Co-authored-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/switch.rs | 6 +++++- crates/lib/src/bootc_composefs/update.rs | 6 +++++- crates/lib/src/cli.rs | 23 ++++++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index ee5f622d3..05f5ced18 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -10,7 +10,7 @@ use crate::{ validate_update, }, }, - cli::{SwitchOpts, imgref_for_switch}, + cli::{SwitchOpts, imgref_for_switch, target_imgref_for_switch}, progress_jsonl::ProgressWriter, store::{BootedComposefs, Storage}, }; @@ -117,6 +117,8 @@ pub(crate) async fn switch_composefs( } }; + let final_target_imgref = target_imgref_for_switch(&opts)?; + if let Some(cfg_verity) = image { let action = validate_update( storage, @@ -139,6 +141,7 @@ pub(crate) async fn switch_composefs( booted_cfs, &host, &target_imgref, + final_target_imgref.as_ref(), &do_upgrade_opts, &manifest, ) @@ -152,6 +155,7 @@ pub(crate) async fn switch_composefs( booted_cfs, &host, &target_imgref, + final_target_imgref.as_ref(), &do_upgrade_opts, &manifest, ) diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 7fc373d9b..71f38acd8 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -318,6 +318,7 @@ pub(crate) async fn do_upgrade( booted_cfs: &BootedComposefs, host: &Host, imgref: &ImageReference, + target_imgref: Option<&ImageReference>, opts: &DoUpgradeOpts<'_>, manifest: &ostree_ext::oci_spec::image::ImageManifest, ) -> Result<()> { @@ -465,7 +466,7 @@ pub(crate) async fn do_upgrade( write_composefs_state( &Utf8PathBuf::from("/sysroot"), &deploy_id, - imgref, + target_imgref.unwrap_or(imgref), false, boot_type, boot_digest, @@ -723,6 +724,7 @@ pub(crate) async fn upgrade_composefs( composefs, &host, booted_imgref, + None, &do_upgrade_opts, &manifest, ) @@ -755,6 +757,7 @@ pub(crate) async fn upgrade_composefs( composefs, &host, booted_imgref, + None, &do_upgrade_opts, &manifest, ) @@ -775,6 +778,7 @@ pub(crate) async fn upgrade_composefs( composefs, &host, booted_imgref, + None, &do_upgrade_opts, &manifest, ) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0059f45f8..21a861ed9 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -31,7 +31,7 @@ use indoc::indoc; use ocidir::cap_std::ambient_authority; use ostree::gio; use ostree_container::store::PrepareResult; -use ostree_ext::container as ostree_container; +use ostree_ext::{container as ostree_container, containers_image_proxy}; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; @@ -247,6 +247,11 @@ pub(crate) struct SwitchOpts { // This is kinda unfortunate that we can't gate this only for composefs systems #[clap(flatten)] pub(crate) uki_addon_opts: UkiAddonOpts, + + /// The image to use for subsequent upgrades + /// Useful when switching to an, say containers-storage, image + /// but subsequent updates are to be pulled from registry + pub(crate) target_imgref: Option, } /// Finalize a staged composefs deployment. @@ -1693,6 +1698,21 @@ async fn upgrade( Ok(()) } +#[context("Getting target imgref for switch")] +pub(crate) fn target_imgref_for_switch(opts: &SwitchOpts) -> Result> { + let Some(target_imgref) = &opts.target_imgref else { + return Ok(None); + }; + + let imgref = containers_image_proxy::ImageReference::try_from(target_imgref.as_str()) + .with_context(|| format!("Parsing imgref {target_imgref}"))?; + + let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy); + let target = ostree_container::OstreeImageReference { sigverify, imgref }; + + Ok(Some(ImageReference::from(target))) +} + #[context("Getting imgref for switch")] pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result { let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; @@ -1888,6 +1908,7 @@ async fn switch(opts: SwitchOpts) -> Result<()> { println!("Updated {deployid} to pull from {target}"); return Ok(()); } + let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(booted_ostree) => { From 471dcc4768da159c3f2e0ba6255b1024c0e1bb01 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 13 Oct 2026 09:47:15 +0530 Subject: [PATCH 02/10] composefs: Record the delivery source for redirected staging Keep the pulled image name alongside a staged deployment when switch chooses a different update origin. This prepares the staged state to distinguish the delivery source from the recorded origin. Signed-off-by: Pragyan Poudyal Co-authored-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/aboot.rs | 1 + crates/lib/src/bootc_composefs/status.rs | 8 ++++++++ crates/lib/src/bootc_composefs/switch.rs | 3 +++ crates/lib/src/bootc_composefs/update.rs | 1 + crates/lib/src/cli.rs | 24 ++++++++++++++++++++++-- crates/lib/src/deploy.rs | 8 +++++++- crates/lib/src/install.rs | 2 +- 7 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index a0c93d0d1..ae64d8810 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -97,6 +97,7 @@ impl PendingDeployment { StagedDeployment { depl_id: self.depl_id.clone(), finalization_locked: self.finalization_locked, + original_imgref: None, } } } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe..9c28b8f05 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -159,6 +159,12 @@ pub(crate) struct StagedDeployment { /// Whether to finalize this staged deployment on reboot or not /// This also maps to `download_only` field in `BootEntry` pub(crate) finalization_locked: bool, + /// This is the image that's pulled into the composefs repository + /// Might or might not be the same as the imgref in the ".origin" file + /// because of the `--target-imgref` flag in `bootc switch` + /// + /// Is only `Some` if target-imgref is different than the image pulled + pub(crate) original_imgref: Option, } #[derive(Debug, PartialEq)] @@ -1337,6 +1343,7 @@ mod tests { let staged = StagedDeployment { depl_id: staged_id.clone(), finalization_locked: true, + original_imgref: None, }; let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; let staged_entry = host.status.staged.as_ref().unwrap(); @@ -1355,6 +1362,7 @@ mod tests { let staged = StagedDeployment { depl_id: b.clone(), finalization_locked: false, + original_imgref: None, }; let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; assert_eq!( diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 05f5ced18..c8609b2e6 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -56,6 +56,9 @@ pub(crate) async fn switch_composefs( new_spec }; + println!("new_spec: {new_spec:#?}"); + println!("host.spec: {:#?}", host.spec); + if new_spec == host.spec { println!("Image specification is unchanged."); if opts.apply && host.status.staged.is_some() { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 71f38acd8..cc12f8056 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -461,6 +461,7 @@ pub(crate) async fn do_upgrade( let staged_state = StagedDeployment { depl_id: deploy_id.to_hex(), finalization_locked: opts.download_only, + original_imgref: target_imgref.map(|_| imgref.to_string()), }; write_composefs_state( diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 21a861ed9..2a1146d32 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -251,6 +251,7 @@ pub(crate) struct SwitchOpts { /// The image to use for subsequent upgrades /// Useful when switching to an, say containers-storage, image /// but subsequent updates are to be pulled from registry + #[clap(long)] pub(crate) target_imgref: Option, } @@ -1666,6 +1667,7 @@ async fn upgrade( &spec, prog.clone(), opts.download_opts.download_only, + None, ) .await?; changed = true; @@ -1758,7 +1760,7 @@ async fn switch_ostree( } let target = imgref_for_switch(&opts)?; - let prog: ProgressWriter = opts.progress.try_into()?; + let prog: ProgressWriter = opts.progress.clone().try_into()?; let cancellable = gio::Cancellable::NONE; let repo = &booted_ostree.repo(); @@ -1769,6 +1771,13 @@ async fn switch_ostree( new_spec }; + // 1. Check if there's a staged one and compare the image digest + // 2. Also need to make sure that `--target-imgeref hi ` and `--target-imgeref hi ` + // actually ends up switching + + println!("new_spec: {new_spec:#?}"); + println!("host.spec: {:#?}", host.spec); + if new_spec == host.spec { println!("Image specification is unchanged."); if opts.apply && host.status.staged.is_some() { @@ -1856,6 +1865,7 @@ async fn switch_ostree( let stateroot = booted_ostree.stateroot(); let from = MergeState::from_stateroot(storage, &stateroot)?; + crate::deploy::stage( storage, from, @@ -1863,6 +1873,7 @@ async fn switch_ostree( &new_spec, prog.clone(), opts.download_opts.download_only, + target_imgref_for_switch(&opts)?.as_ref(), ) .await?; @@ -2010,7 +2021,16 @@ async fn edit_ostree( let stateroot = booted_ostree.stateroot(); let from = MergeState::from_stateroot(storage, &stateroot)?; - crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?; + crate::deploy::stage( + storage, + from, + &fetched, + &new_spec, + prog.clone(), + false, + None, + ) + .await?; storage.update_mtime()?; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 1f19afc62..8111f731f 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -1255,6 +1255,7 @@ pub(crate) async fn stage( spec: &RequiredHostSpec<'_>, prog: ProgressWriter, lock_finalization: bool, + target_imgref: Option<&ImageReference>, ) -> Result<()> { // Log the staging operation to systemd journal with comprehensive upgrade information const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c"; @@ -1333,7 +1334,12 @@ pub(crate) async fn stage( .collect(), }) .await; - let origin = origin_from_imageref(spec.image)?; + + let origin = match target_imgref { + Some(i) => origin_from_imageref(i)?, + None => origin_from_imageref(spec.image)?, + }; + crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?; subtask.completed = true; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 36ae98393..efcccc199 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -3197,7 +3197,7 @@ pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> { stateroot: target_stateroot.clone(), kargs, }; - crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone(), false).await?; + crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone(), false, None).await?; // Copy /boot entry from /etc/fstab to the new stateroot if it exists if let Some(boot_spec) = read_boot_fstab_entry(rootfs)? { From 34cb0cf43405995488ac052e736bc352caeb5c3c Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 14:58:39 +0530 Subject: [PATCH 03/10] switch: Validate and normalize target image references Offline delivery should accept the same registry names users pass to install. Normalize target references before fetching, reject incompatible options, and avoid advertising enforced verification on an unsupported composefs path. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/switch.rs | 5 + crates/lib/src/cli.rs | 111 +++++++++++++++++++++-- crates/lib/src/spec.rs | 23 +++++ docs/src/man/bootc-switch.8.md | 4 + 4 files changed, 133 insertions(+), 10 deletions(-) diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index c8609b2e6..0462c40e0 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -45,6 +45,11 @@ pub(crate) async fn switch_composefs( return apply_upgrade_from_downloaded(storage, booted_cfs, &host, &do_upgrade_opts).await; } + anyhow::ensure!( + opts.target_imgref.is_none() || !opts.enforce_container_sigpolicy, + "--target-imgref with --enforce-container-sigpolicy is not supported by the composefs backend" + ); + let target = imgref_for_switch(&opts)?; if let Some(delta) = delta.as_ref() { delta.validate_image_reference(&target)?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 2a1146d32..2dc12c13a 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -248,11 +248,16 @@ pub(crate) struct SwitchOpts { #[clap(flatten)] pub(crate) uki_addon_opts: UkiAddonOpts, - /// The image to use for subsequent upgrades - /// Useful when switching to an, say containers-storage, image - /// but subsequent updates are to be pulled from registry - #[clap(long)] - pub(crate) target_imgref: Option, + /// Record a different image reference for subsequent updates. + /// + /// The positional image and --transport select where to fetch now. This + /// reference is recorded without fetching it. Accepts a registry image name + /// or a transport-prefixed reference, such as docker://quay.io/example/os:latest. + /// Repeating the same source and update origin leaves the deployment unchanged. + /// Use --from-downloaded to unlock a deployment staged with --download-only. + #[clap(long, value_parser = crate::spec::parse_image_reference, + conflicts_with_all = ["from_downloaded", "mutate_in_place", "from_delta"])] + pub(crate) target_imgref: Option, } /// Finalize a staged composefs deployment. @@ -1706,13 +1711,13 @@ pub(crate) fn target_imgref_for_switch(opts: &SwitchOpts) -> Result Result { mod tests { use super::*; + #[test] + fn test_switch_target_imgref() -> Result<()> { + for (value, transport, image) in [ + ("quay.io/example/os:v2", "registry", "quay.io/example/os:v2"), + ( + "docker://quay.io/example/os:v2", + "registry", + "quay.io/example/os:v2", + ), + ("localhost:5000/os:v2", "registry", "localhost:5000/os:v2"), + ("oci:5000/os:v2", "registry", "oci:5000/os:v2"), + ("docker:5000/os:v2", "registry", "docker:5000/os:v2"), + ("registry:5000/os:v2", "registry", "registry:5000/os:v2"), + ( + "registry:quay.io/example/os:v2", + "registry", + "quay.io/example/os:v2", + ), + ( + "containers-storage:localhost/os:v2", + "containers-storage", + "localhost/os:v2", + ), + ("oci:/var/tmp/os", "oci", "/var/tmp/os"), + ("oci:./5000/os", "oci", "./5000/os"), + ("oci:relative/os", "oci", "relative/os"), + ] { + let Opt::Switch(opts) = Opt::try_parse_from([ + "bootc", + "switch", + "--transport", + "containers-storage", + "--target-imgref", + value, + "localhost/offline:v2", + ])? + else { + unreachable!() + }; + let source = imgref_for_switch(&opts)?; + assert_eq!(source.transport, "containers-storage"); + assert_eq!(source.image, "localhost/offline:v2"); + let parsed = opts.target_imgref.as_ref().unwrap(); + assert_eq!( + parsed.transport, + ostree_container::Transport::try_from(transport)? + ); + assert_eq!(parsed.name, image); + let target = target_imgref_for_switch(&opts)?.unwrap(); + assert_eq!(target.transport, transport); + assert_eq!(target.image, image); + } + for value in [ + "", + "docker:quay.io/example/os", + "docker://", + "oci:", + "https://quay.io/example/os", + "bad image", + ] { + let error = Opt::try_parse_from([ + "bootc", + "switch", + "--target-imgref", + value, + "localhost/offline:v2", + ]) + .unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::ValueValidation, + "{value}" + ); + } + for args in [ + vec!["--from-downloaded"], + vec!["--mutate-in-place", "localhost/offline:v2"], + vec!["--from-delta", "/var/tmp/delta", "localhost/offline:v2"], + ] { + let mut argv = vec!["bootc", "switch", "--target-imgref", "quay.io/example/os"]; + argv.extend(args); + assert!(Opt::try_parse_from(argv).is_err()); + } + Ok(()) + } + #[test] fn test_callname() { use std::os::unix::ffi::OsStrExt; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index ab3428d31..8786fbe25 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use anyhow::Result; use ostree_ext::container::Transport; +use ostree_ext::containers_image_proxy; use ostree_ext::oci_spec::distribution::Reference; use ostree_ext::oci_spec::image::Digest; use ostree_ext::{container::OstreeImageReference, oci_spec, ostree::DeploymentUnlockedState}; @@ -95,6 +96,28 @@ pub struct ImageReference { pub signature: Option, } +/// Parse a transport-prefixed reference, defaulting bare image names to the registry. +pub(crate) fn parse_image_reference(value: &str) -> Result { + // A numeric port identifies a bare registry reference even when the host + // is named after a transport. Use oci:./5000/os for an OCI relative path. + let registry_port = value.split_once('/').is_some_and(|(authority, _)| { + authority + .rsplit_once(':') + .is_some_and(|(_, port)| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())) + }); + let imgref = if !registry_port + && value.split_once(':').is_some_and(|(transport, _)| { + transport == "docker" || Transport::try_from(transport).is_ok() + }) { + containers_image_proxy::ImageReference::try_from(value)? + } else { + containers_image_proxy::ImageReference::try_new_registry(value)? + }; + // Transport parsing alone does not validate registry image names. + imgref.as_registry().transpose()?; + Ok(imgref) +} + /// If the reference is in :tag@digest form, strip the tag. fn canonicalize_reference(reference: Reference) -> Option { // No tag? Just pass through. diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index 5e6b3d3b3..c7f2b8ac8 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -88,6 +88,10 @@ For shared `--apply` and `--soft-reboot` behavior, see Experimental. Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top +**--target-imgref**=*TARGET_IMGREF* + + Record a different image reference for subsequent updates + # EXAMPLES From 80a484388b21fc63ddee3ccb5b7a66a146f33da3 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 14:59:19 +0530 Subject: [PATCH 04/10] ostree: Retain target references for cached image imports A cached import must retain the reference used by the deployment origin so cleanup does not discard its image metadata. Handle this in the importer, respecting set_no_imgref, so direct and unified pulls share the same behavior. Reuse the existing pull preparation helper. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- crates/lib/src/deploy.rs | 17 +----- crates/ostree-ext/src/container/store.rs | 40 +++++++++----- crates/ostree-ext/tests/it/main.rs | 67 ++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 28 deletions(-) diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 8111f731f..0df503137 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -746,12 +746,7 @@ pub(crate) async fn prepare_for_pull_unified( } PrepareResult::Ready(p) => p, }; - check_bootc_label(&prep.config); - if let Some(warning) = prep.deprecated_warning() { - ostree_ext::cli::print_deprecated_warning(warning).await; - } - ostree_ext::cli::print_layer_status(&prep); - let layers_to_fetch = prep.layers_to_fetch().collect::>>()?; + let prepared_image = prepared_import_meta(imp, prep).await?; // Log that we're importing a new image from containers-storage const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0"; @@ -765,16 +760,6 @@ pub(crate) async fn prepare_for_pull_unified( ostree_imgref ); - let prepared_image = PreparedImportMeta { - imp, - n_layers_to_fetch: layers_to_fetch.len(), - layers_total: prep.all_layers().count(), - bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(), - bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(), - digest: prep.manifest_digest.clone(), - prep, - }; - Ok(PreparedPullResult::Ready(Box::new(prepared_image))) } diff --git a/crates/ostree-ext/src/container/store.rs b/crates/ostree-ext/src/container/store.rs index 9316d8547..1ee6649c2 100644 --- a/crates/ostree-ext/src/container/store.rs +++ b/crates/ostree-ext/src/container/store.rs @@ -709,6 +709,17 @@ impl ImageImporter { self.target_imgref = Some(target.clone()) } + fn already_present(&self, state: Box) -> Result { + // A cache hit skips import(), but must retain the requested target just + // as a fresh import would. Leave existing source refs to normal cleanup. + if !self.no_imgref + && let Some(target) = &self.target_imgref + { + set_image_reference(&self.repo, &target.imgref, &state.merge_commit)?; + } + Ok(PrepareResult::AlreadyPresent(state)) + } + /// Do not write the final image ref, but do write refs for shared layers. /// This is useful in scenarios where you want to "pre-pull" an image, /// but in such a way that it does not need to be manually removed later. @@ -1087,7 +1098,7 @@ impl ImageImporter { &manifest_digest, manifest.config().digest(), ) { - PreviousState::UpToDate(state) => return Ok(PrepareResult::AlreadyPresent(state)), + PreviousState::UpToDate(state) => return self.already_present(state), PreviousState::Outdated(previous) => previous.unzip(), }; @@ -1124,7 +1135,7 @@ impl ImageImporter { // A digested pull spec, and our existing state matches. if previous_state.manifest_digest == target_digest { tracing::debug!("Digest-based pullspec {:?} already present", self.imgref); - return Ok(PrepareResult::AlreadyPresent(previous_state)); + return self.already_present(previous_state); } Some(previous_state) } else { @@ -1155,7 +1166,7 @@ impl ImageImporter { &manifest_digest, manifest.config().digest(), ) { - PreviousState::UpToDate(state) => return Ok(PrepareResult::AlreadyPresent(state)), + PreviousState::UpToDate(state) => return self.already_present(state), PreviousState::Outdated(previous) => previous.unzip(), }; @@ -1902,6 +1913,19 @@ pub fn query_image_commit(repo: &ostree::Repo, commit: &str) -> Result Result<()> { + repo.set_ref_immediate( + None, + &ref_for_image(imgref)?, + Some(commit), + gio::Cancellable::NONE, + )?; + Ok(()) +} + fn manifest_for_image(repo: &ostree::Repo, imgref: &ImageReference) -> Result { let ostree_ref = ref_for_image(imgref)?; let rev = repo.require_rev(&ostree_ref)?; @@ -1949,15 +1973,7 @@ pub async fn copy( .await?; } - let dest_ostree_ref = ref_for_image(dest_imgref)?; - dest_repo.set_ref_immediate( - None, - &dest_ostree_ref, - Some(&src_commit), - gio::Cancellable::NONE, - )?; - - Ok(()) + set_image_reference(dest_repo, dest_imgref, &src_commit) } /// Options controlling commit export into OCI diff --git a/crates/ostree-ext/tests/it/main.rs b/crates/ostree-ext/tests/it/main.rs index 8f973d3c4..bd14f1304 100644 --- a/crates/ostree-ext/tests/it/main.rs +++ b/crates/ostree-ext/tests/it/main.rs @@ -757,14 +757,70 @@ async fn test_no_fetch_digested() -> Result<()> { .unwrap(); // And the key test, we shouldn't reach out to the registry here imp.set_offline(); + let redirected = OstreeImageReference { + imgref: ImageReference::try_from("docker://quay.io/exampleos/redirected:latest")?, + ..target_imgref.clone() + }; + imp.set_target(&redirected); match imp.prepare().await.context("Init prep derived").unwrap() { store::PrepareResult::AlreadyPresent(_) => {} store::PrepareResult::Ready(_) => panic!("Should have image already"), }; + assert_eq!( + store::query_image(fixture.destrepo(), &redirected.imgref)? + .unwrap() + .merge_commit, + r.merge_commit + ); Ok(()) } +#[tokio::test] +async fn test_cached_import_target() -> Result<()> { + if !check_skopeo() { + return Ok(()); + } + let fixture = Fixture::new_v1()?; + let (source, _) = fixture.export_container().await?; + let imported = fixture.must_import(&source).await?; + let source = OstreeImageReference { + sigverify: SignatureSource::ContainerPolicyAllowInsecure, + imgref: source, + }; + let target = OstreeImageReference { + imgref: ImageReference::try_from("docker://quay.io/exampleos/cached:latest")?, + ..source.clone() + }; + for no_imgref in [true, false] { + let mut imp = + store::ImageImporter::new(fixture.destrepo(), &source, Default::default()).await?; + imp.set_target(&target); + if no_imgref { + imp.set_no_imgref(); + } + let store::PrepareResult::AlreadyPresent(state) = imp.prepare().await? else { + panic!("Cached image must not require another import"); + }; + assert_eq!(state.merge_commit, imported.merge_commit); + let retained = store::query_image(fixture.destrepo(), &target.imgref)?; + if no_imgref { + assert!(retained.is_none()); + } else { + assert_eq!(retained.unwrap().merge_commit, imported.merge_commit); + } + } + // Source aliases remain independently removable; the target retains the layers. + assert!(store::remove_image(fixture.destrepo(), &source.imgref)?); + assert_eq!(store::gc_image_layers(fixture.destrepo())?, 0); + assert!(store::query_image(fixture.destrepo(), &source.imgref)?.is_none()); + assert_eq!(store::list_images(fixture.destrepo())?.len(), 1); + assert!(store::remove_image(fixture.destrepo(), &target.imgref)?); + assert!(store::gc_image_layers(fixture.destrepo())? > 0); + assert!(store::list_images(fixture.destrepo())?.is_empty()); + Ok(()) +} + /// A [`LayerSource`] which reads blobs straight out of an OCI directory, /// standing in for anything that produces layer content without a registry. #[derive(Debug)] @@ -850,10 +906,21 @@ async fn test_prepare_from_manifest() -> Result<()> { // And now it is present, established without ever contacting a registry. let mut imp = store::ImageImporter::new_without_proxy(fixture.destrepo(), &imgref)?; + let target = OstreeImageReference { + imgref: ImageReference::try_from("docker://quay.io/exampleos/from-manifest:latest")?, + ..imgref.clone() + }; + imp.set_target(&target); let prep = imp .prepare_from_manifest(expected_digest, manifest, config, open_source()?) .await?; assert!(matches!(prep, store::PrepareResult::AlreadyPresent(_))); + assert_eq!( + store::query_image(fixture.destrepo(), &target.imgref)? + .unwrap() + .merge_commit, + state.merge_commit + ); Ok(()) } From 94f3ed83750c40fac3612c557a7897d945b08912 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 14:59:56 +0530 Subject: [PATCH 05/10] switch: Persist source provenance for repeated requests The update origin cannot identify which offline delivery was staged. Record the pull source in each deployment origin and compare both references before fetching, preserving download-only locks on repeats. Route OSTree imports through the source while retaining the target. Cover offline repeats, source replacement, activation, and provenance after reboot on both backends. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/aboot.rs | 1 - crates/lib/src/bootc_composefs/boot.rs | 1 + crates/lib/src/bootc_composefs/state.rs | 20 +- crates/lib/src/bootc_composefs/status.rs | 8 - crates/lib/src/bootc_composefs/switch.rs | 63 ++++--- crates/lib/src/bootc_composefs/update.rs | 2 +- crates/lib/src/cli.rs | 174 +++++++++++++++--- crates/lib/src/deploy.rs | 58 +++++- tmt/plans/integration.fmf | 7 + tmt/tests/booted/test-switch-target-imgref.nu | 96 ++++++++++ tmt/tests/tests.fmf | 5 + 11 files changed, 364 insertions(+), 71 deletions(-) create mode 100644 tmt/tests/booted/test-switch-target-imgref.nu diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index ae64d8810..a0c93d0d1 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -97,7 +97,6 @@ impl PendingDeployment { StagedDeployment { depl_id: self.depl_id.clone(), finalization_locked: self.finalization_locked, - original_imgref: None, } } } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index e01a58716..4451fc581 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2926,6 +2926,7 @@ pub(crate) async fn setup_composefs_boot( &root_setup.physical_root_path, &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), + None, true, boot_type, boot_digest, diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 57055a3de..04d552258 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -255,6 +255,7 @@ pub(crate) async fn write_composefs_state( root_path: &Utf8PathBuf, deployment_id: &Sha512HashValue, target_imgref: &ImageReference, + source_imgref: Option<&ImageReference>, initialize_var: bool, boot_type: BootType, boot_digest: String, @@ -286,12 +287,19 @@ pub(crate) async fn write_composefs_state( )?; let imgref = target_imgref.to_image_proxy_ref()?; - - let mut config = tini::Ini::new().section("origin").item( - ORIGIN_CONTAINER, - // TODO (Johan-Liebert1): The image won't always be unverified - format!("ostree-unverified-image:{imgref}"), - ); + let mut config = tini::Ini::new() + .section("origin") + // The composefs pull path does not yet enforce the spec signature mode. + .item( + ORIGIN_CONTAINER, + format!("ostree-unverified-image:{imgref}"), + ); + + if let Some(source) = source_imgref.filter(|source| *source != target_imgref) { + config = config + .section(crate::deploy::ORIGIN_BOOTC) + .item(crate::deploy::ORIGIN_SOURCE_IMAGE, source.to_string()); + } config = config .section(ORIGIN_KEY_BOOT) diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 9c28b8f05..dcc1282fe 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -159,12 +159,6 @@ pub(crate) struct StagedDeployment { /// Whether to finalize this staged deployment on reboot or not /// This also maps to `download_only` field in `BootEntry` pub(crate) finalization_locked: bool, - /// This is the image that's pulled into the composefs repository - /// Might or might not be the same as the imgref in the ".origin" file - /// because of the `--target-imgref` flag in `bootc switch` - /// - /// Is only `Some` if target-imgref is different than the image pulled - pub(crate) original_imgref: Option, } #[derive(Debug, PartialEq)] @@ -1343,7 +1337,6 @@ mod tests { let staged = StagedDeployment { depl_id: staged_id.clone(), finalization_locked: true, - original_imgref: None, }; let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; let staged_entry = host.status.staged.as_ref().unwrap(); @@ -1362,7 +1355,6 @@ mod tests { let staged = StagedDeployment { depl_id: b.clone(), finalization_locked: false, - original_imgref: None, }; let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; assert_eq!( diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 0462c40e0..da803aa2f 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -10,7 +10,10 @@ use crate::{ validate_update, }, }, - cli::{SwitchOpts, imgref_for_switch, target_imgref_for_switch}, + cli::{ + SwitchOpts, handle_unchanged_switch, imgref_for_switch, switch_images_unchanged, + target_imgref_for_switch, + }, progress_jsonl::ProgressWriter, store::{BootedComposefs, Storage}, }; @@ -50,38 +53,44 @@ pub(crate) async fn switch_composefs( "--target-imgref with --enforce-container-sigpolicy is not supported by the composefs backend" ); - let target = imgref_for_switch(&opts)?; + let source = imgref_for_switch(&opts)?; + let final_target_imgref = target_imgref_for_switch(&opts)?; + let origin = final_target_imgref.as_ref().unwrap_or(&source); if let Some(delta) = delta.as_ref() { - delta.validate_image_reference(&target)?; + delta.validate_image_reference(&source)?; } - let new_spec = { - let mut new_spec = host.spec.clone(); - new_spec.image = Some(target.clone()); - new_spec - }; - - println!("new_spec: {new_spec:#?}"); - println!("host.spec: {:#?}", host.spec); - - if new_spec == host.spec { - println!("Image specification is unchanged."); - if opts.apply && host.status.staged.is_some() { - crate::reboot::reboot()?; - } - return Ok(()); + let current = host + .status + .staged + .as_ref() + .or(host.status.booted.as_ref()) + .context("No current deployment")?; + let current_origin = + super::state::read_origin(&storage.physical_root, ¤t.require_composefs()?.verity)? + .context("No origin for current deployment")?; + let previous_source = current_origin.get::( + crate::deploy::ORIGIN_BOOTC, + crate::deploy::ORIGIN_SOURCE_IMAGE, + ); + if switch_images_unchanged( + &source, + final_target_imgref.as_ref(), + host.spec.image.as_ref(), + previous_source.as_deref(), + )? { + return handle_unchanged_switch(&opts, &host); } - let Some(target_imgref) = new_spec.image else { - anyhow::bail!("Target image is undefined") - }; + let target_imgref = &source; const COMPOSEFS_SWITCH_JOURNAL_ID: &str = "7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1"; tracing::info!( message_id = COMPOSEFS_SWITCH_JOURNAL_ID, bootc.operation = "switch", - bootc.target_image = target_imgref.to_string(), + bootc.target_image = origin.to_string(), + bootc.source_image = target_imgref.to_string(), bootc.apply_mode = opts.apply, bootc.download_only = opts.download_opts.download_only, bootc.from_downloaded = opts.download_opts.from_downloaded, @@ -104,7 +113,7 @@ pub(crate) async fn switch_composefs( false }; let target_unified = - crate::deploy::image_exists_in_unified_storage(storage, &target_imgref).await?; + crate::deploy::image_exists_in_unified_storage(storage, target_imgref).await?; booted_unified || target_unified }; @@ -120,13 +129,11 @@ pub(crate) async fn switch_composefs( ) } None => { - let (image, img_config, _) = is_image_pulled(repo, &target_imgref).await?; + let (image, img_config, _) = is_image_pulled(repo, target_imgref).await?; (image, img_config.manifest) } }; - let final_target_imgref = target_imgref_for_switch(&opts)?; - if let Some(cfg_verity) = image { let action = validate_update( storage, @@ -148,7 +155,7 @@ pub(crate) async fn switch_composefs( storage, booted_cfs, &host, - &target_imgref, + target_imgref, final_target_imgref.as_ref(), &do_upgrade_opts, &manifest, @@ -162,7 +169,7 @@ pub(crate) async fn switch_composefs( storage, booted_cfs, &host, - &target_imgref, + target_imgref, final_target_imgref.as_ref(), &do_upgrade_opts, &manifest, diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index cc12f8056..243fd6194 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -461,13 +461,13 @@ pub(crate) async fn do_upgrade( let staged_state = StagedDeployment { depl_id: deploy_id.to_hex(), finalization_locked: opts.download_only, - original_imgref: target_imgref.map(|_| imgref.to_string()), }; write_composefs_state( &Utf8PathBuf::from("/sysroot"), &deploy_id, target_imgref.unwrap_or(imgref), + Some(imgref), false, boot_type, boot_digest, diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 2dc12c13a..819ab7243 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1720,6 +1720,48 @@ pub(crate) fn target_imgref_for_switch(opts: &SwitchOpts) -> Result, + current_origin: Option<&ImageReference>, + previous_source: Option<&str>, +) -> Result { + let Some(current_origin) = current_origin else { + return Ok(false); + }; + if target.unwrap_or(source) != current_origin { + return Ok(false); + } + // Ordinary switches compare only the update origin. Provenance matters + // when a target override distinguishes it from a delivery source. + if target.is_none() { + return Ok(true); + } + let previous_source = previous_source + .map(ostree_container::OstreeImageReference::try_from) + .transpose() + .context("Parsing deployment pull source")? + .map(ImageReference::from) + .unwrap_or_else(|| current_origin.clone()); + Ok(source.clone().canonicalize()? == previous_source.canonicalize()?) +} + +pub(crate) fn handle_unchanged_switch(opts: &SwitchOpts, host: &Host) -> Result<()> { + if opts.apply + && let Some(staged) = &host.status.staged + { + anyhow::ensure!( + !staged.download_only, + "Staged deployment is download-only; use bootc switch --from-downloaded --apply" + ); + crate::reboot::reboot()?; + } + println!("Image specification is unchanged."); + Ok(()) +} + #[context("Getting imgref for switch")] pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result { let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; @@ -1749,7 +1791,7 @@ async fn switch_ostree( .await? .map(std::sync::Arc::new); - let (_, host) = crate::status::get_status(booted_ostree)?; + let (deployments, host) = crate::status::get_status(booted_ostree)?; if opts.download_opts.from_downloaded { return apply_from_downloaded_ostree( @@ -1764,7 +1806,12 @@ async fn switch_ostree( .await; } - let target = imgref_for_switch(&opts)?; + let source = imgref_for_switch(&opts)?; + let target_imgref = target_imgref_for_switch(&opts)?; + let target = target_imgref.as_ref().unwrap_or(&source); + let import_target = target_imgref + .clone() + .map(ostree_container::OstreeImageReference::from); let prog: ProgressWriter = opts.progress.clone().try_into()?; let cancellable = gio::Cancellable::NONE; @@ -1776,19 +1823,27 @@ async fn switch_ostree( new_spec }; - // 1. Check if there's a staged one and compare the image digest - // 2. Also need to make sure that `--target-imgeref hi ` and `--target-imgeref hi ` - // actually ends up switching - - println!("new_spec: {new_spec:#?}"); - println!("host.spec: {:#?}", host.spec); - - if new_spec == host.spec { - println!("Image specification is unchanged."); - if opts.apply && host.status.staged.is_some() { - crate::reboot::reboot()?; - } - return Ok(()); + let current = deployments + .staged + .as_ref() + .unwrap_or(&booted_ostree.deployment); + let previous_source = current + .origin() + .map(|origin| { + origin.optional_string( + crate::deploy::ORIGIN_BOOTC, + crate::deploy::ORIGIN_SOURCE_IMAGE, + ) + }) + .transpose()? + .flatten(); + if switch_images_unchanged( + &source, + target_imgref.as_ref(), + host.spec.image.as_ref(), + previous_source.as_deref(), + )? { + return handle_unchanged_switch(&opts, &host); } // Log the switch operation to systemd journal @@ -1805,6 +1860,8 @@ async fn switch_ostree( bootc.old_image_reference = old_image, bootc.new_image_reference = &target.image, bootc.new_image_transport = &target.transport, + bootc.source_image_reference = &source.image, + bootc.source_image_transport = &source.transport, "Switching from image {} to {}", old_image, target.image @@ -1818,7 +1875,7 @@ async fn switch_ostree( let use_unified = if opts.unified_storage_exp { true } else { - crate::deploy::image_exists_in_unified_storage(storage, &target).await? + crate::deploy::image_exists_in_unified_storage(storage, &source).await? }; if let Some(delta) = delta.as_deref() { crate::delta::reject_unified_storage(delta, use_unified)?; @@ -1827,7 +1884,7 @@ async fn switch_ostree( let fetched = if let Some(delta) = delta.clone() { crate::deploy::pull_delta( repo, - &target, + &source, delta, opts.quiet, prog.clone(), @@ -1837,8 +1894,8 @@ async fn switch_ostree( } else if use_unified { crate::deploy::pull_unified( repo, - &target, - None, + &source, + import_target.as_ref(), opts.quiet, prog.clone(), storage, @@ -1848,8 +1905,8 @@ async fn switch_ostree( } else { crate::deploy::pull( repo, - &target, - None, + &source, + import_target.as_ref(), opts.quiet, prog.clone(), Some(&booted_ostree.deployment), @@ -1878,7 +1935,7 @@ async fn switch_ostree( &new_spec, prog.clone(), opts.download_opts.download_only, - target_imgref_for_switch(&opts)?.as_ref(), + Some(&source), ) .await?; @@ -3111,6 +3168,79 @@ mod tests { Ok(()) } + #[test] + fn test_switch_images_unchanged() -> Result<()> { + let origin = ImageReference::from(ostree_container::OstreeImageReference::try_from( + "ostree-unverified-registry:quay.io/example/os:v2", + )?); + let source = ImageReference { + image: "localhost/offline:v2".into(), + transport: "containers-storage".into(), + signature: None, + }; + let other_source = ImageReference { + image: "localhost/offline:v3".into(), + ..source.clone() + }; + let other_origin = ImageReference { + image: "quay.io/example/os:v3".into(), + ..origin.clone() + }; + let signed_source = ImageReference { + signature: Some(crate::spec::ImageSignature::ContainerPolicy), + ..source.clone() + }; + let saved_source = source.to_string(); + for (requested_source, requested_origin, previous, expected) in [ + (&origin, None, None, true), + (&origin, None, Some(saved_source.as_str()), true), + (&origin, None, Some("invalid"), true), + (&source, None, Some(saved_source.as_str()), false), + (&origin, Some(&origin), Some(saved_source.as_str()), false), + (&source, Some(&origin), None, false), + (&source, Some(&origin), Some(saved_source.as_str()), true), + ( + &other_source, + Some(&origin), + Some(saved_source.as_str()), + false, + ), + ( + &source, + Some(&other_origin), + Some(saved_source.as_str()), + false, + ), + ( + &signed_source, + Some(&origin), + Some(saved_source.as_str()), + false, + ), + ] { + assert_eq!( + switch_images_unchanged( + requested_source, + requested_origin, + Some(&origin), + previous + )?, + expected + ); + } + assert!(!switch_images_unchanged( + &source, + Some(&origin), + None, + None + )?); + assert!( + switch_images_unchanged(&source, Some(&origin), Some(&origin), Some("invalid")) + .is_err() + ); + Ok(()) + } + #[test] fn test_callname() { use std::os::unix::ffi::OsStrExt; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 0df503137..f69e75ab0 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -78,6 +78,10 @@ use crate::utils::async_task_with_spinner; // TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc"; +/// Deployment-local provenance; only the normal origin controls future updates. +pub(crate) const ORIGIN_BOOTC: &str = "bootc"; +pub(crate) const ORIGIN_SOURCE_IMAGE: &str = "source-image-reference"; + // Match podman's default registry retry policy. A failed attempt has to rebuild // the importer, so retries are made at the whole-pull boundary instead of for // individual layers. @@ -1168,6 +1172,17 @@ fn origin_from_imageref(imgref: &ImageReference) -> Result { Ok(origin) } +fn origin_for_switch( + image: &ImageReference, + source: Option<&ImageReference>, +) -> Result { + let origin = origin_from_imageref(image)?; + if let Some(source) = source.filter(|source| *source != image) { + origin.set_string(ORIGIN_BOOTC, ORIGIN_SOURCE_IMAGE, &source.to_string()); + } + Ok(origin) +} + /// The source of data for staging a new deployment #[derive(Debug)] pub(crate) enum MergeState { @@ -1240,7 +1255,7 @@ pub(crate) async fn stage( spec: &RequiredHostSpec<'_>, prog: ProgressWriter, lock_finalization: bool, - target_imgref: Option<&ImageReference>, + source_imgref: Option<&ImageReference>, ) -> Result<()> { // Log the staging operation to systemd journal with comprehensive upgrade information const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c"; @@ -1320,10 +1335,7 @@ pub(crate) async fn stage( }) .await; - let origin = match target_imgref { - Some(i) => origin_from_imageref(i)?, - None => origin_from_imageref(spec.image)?, - }; + let origin = origin_for_switch(spec.image, source_imgref)?; crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?; @@ -1631,8 +1643,44 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use ostree_ext::keyfileext::KeyFileExt; use tokio::io::{AsyncBufReadExt, BufReader}; + #[test] + fn test_switch_origin_provenance() -> Result<()> { + let target = ImageReference { + image: "quay.io/example/os:v2".into(), + transport: "registry".into(), + signature: None, + }; + let source = ImageReference { + image: "localhost/offline:v2".into(), + transport: "containers-storage".into(), + signature: None, + }; + for (pull_source, expected) in [ + (None, None), + (Some(&target), None), + (Some(&source), Some(source.to_string())), + ] { + let origin = origin_for_switch(&target, pull_source)?; + let reloaded = glib::KeyFile::new(); + reloaded.load_from_data(&origin.to_data(), glib::KeyFileFlags::NONE)?; + assert_eq!( + reloaded.string("origin", ostree_container::deploy::ORIGIN_CONTAINER)?, + target.to_string() + ); + assert_eq!( + reloaded + .optional_string(ORIGIN_BOOTC, ORIGIN_SOURCE_IMAGE)? + .as_deref(), + expected.as_deref() + ); + assert!(!crate::utils::origin_has_rpmostree_stuff(&reloaded)); + } + Ok(()) + } + fn get_blob_failure(error: ostree_ext::containers_image_proxy::GetBlobError) -> anyhow::Error { let error = ostree_ext::containers_image_proxy::Error::BlobError(error); anyhow::Error::from(error).context("Unencapsulating base") diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index bc150a0b7..510203751 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -428,4 +428,11 @@ execute: test: - /tmt/tests/tests/test-63-install-shared-propagation extra-fixme_skip_if_composefs: true + +/plan-64-switch-target-imgref: + summary: Offline switch preserves the update origin and survives repeated requests + discover: + how: fmf + test: + - /tmt/tests/tests/test-64-switch-target-imgref # END GENERATED PLANS diff --git a/tmt/tests/booted/test-switch-target-imgref.nu b/tmt/tests/booted/test-switch-target-imgref.nu new file mode 100644 index 000000000..29367d09f --- /dev/null +++ b/tmt/tests/booted/test-switch-target-imgref.nu @@ -0,0 +1,96 @@ +# number: 64 +# tmt: +# summary: Offline switch preserves the update origin and survives repeated requests +# duration: 40m +# +use std assert +use tap.nu + +const source = "localhost/target-imgref-source:v2" +const origin = "localhost:5555/target-imgref:latest" +const next_origin = "localhost:5555/target-imgref:redirected" +const archive = "/var/tmp/target-imgref-source.tar" +const registry = "bootc-target-imgref-registry" +const marker = "/usr/share/testing-target-imgref" + +def status [] { bootc status --json | from json } + +def build_image [version: string, image: string] { + let containerfile = $"FROM localhost/bootc as base\nRUN echo ($version) > ($marker)\n" + (tap make_uki_containerfile $containerfile) | save --force Containerfile + podman build -t $image -f Containerfile . +} + +def stage [] { + bootc switch --download-only --transport containers-storage --target-imgref $origin $source +} + +def first_boot [] { + tap begin "offline source and persistent update origin" + bootc image copy-to-storage + cd (mktemp -d) + build_image "v1" "localhost/target-imgref-source:v1" + build_image "v2" $source + + mkdir /etc/containers/registries.conf.d + '[[registry]] +location = "localhost:5555" +insecure = true +' | save /etc/containers/registries.conf.d/target-imgref-test.conf + podman run -d --name $registry -p 127.0.0.1:5555:5000 docker.io/library/registry:2 + # Retry startup readiness through podman's push retry handling. + podman push --tls-verify=false $source $origin + # Deliver the exact registry manifest through local storage, as with an + # offline archive. A push can change layer compression and manifest digests. + podman pull --tls-verify=false $origin + podman tag $origin $source + podman rmi $origin + podman save --format oci-archive -o $archive $source + podman stop $registry + + # A new source must still be processed when the update origin is unchanged. + bootc switch --download-only --transport containers-storage --target-imgref $origin localhost/target-imgref-source:v1 + let previous = (status).status.staged.image.imageDigest + # A new source must still be processed when the update origin is unchanged. + stage + let staged = (status).status.staged + assert ($staged.image.imageDigest != $previous) + assert equal $staged.image.image.image $origin + assert equal $staged.image.image.transport "registry" + assert $staged.downloadOnly + + # Remove the source while the target registry is also unavailable. A repeat + # must use deployment metadata without contacting either image location. + podman rmi $source + stage + assert equal (status).status.staged $staged + let applied = do { bootc switch --apply --transport containers-storage --target-imgref $origin $source } | complete + assert ($applied.exit_code != 0) "--apply must not bypass a download-only lock" + assert equal (status).status.staged $staged + + bootc switch --from-downloaded + assert (not (status).status.staged.downloadOnly) + tmt-reboot +} + +def second_boot [] { + let booted = (status).status.booted + assert equal $booted.image.image.image $origin + assert equal (open $marker | str trim) "v2" + + # Provenance survives reboot. The source remains absent and registry stopped. + bootc switch --transport containers-storage --target-imgref $origin $source + assert equal (status).status.booted $booted + assert equal (status).status.staged null + + podman rm $registry + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + $n => { error make { msg: $"Invalid reboot count ($n)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 7b7c80499..5b652b0ce 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -276,3 +276,8 @@ check: summary: Install from a shared mount namespace without leaking the storage bind mount duration: 30m test: nu booted/test-install-shared-propagation.nu + +/test-64-switch-target-imgref: + summary: Offline switch preserves the update origin and survives repeated requests + duration: 40m + test: nu booted/test-switch-target-imgref.nu From f9c10df2942b72a2f49b02053406f5b82634ddb4 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 15:00:04 +0530 Subject: [PATCH 06/10] composefs: Allow origin changes for identical staged images An identical image may be delivered with a different future update origin. Reuse an existing staged image only when its OCI manifest matches exactly, preserve its finalization lock, and avoid rewriting origins shared with another deployment. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/state.rs | 79 +++++++++--------- crates/lib/src/bootc_composefs/switch.rs | 80 ++++++++++++++++++- crates/lib/src/bootc_composefs/update.rs | 41 ++++++++-- tmt/tests/booted/test-switch-target-imgref.nu | 3 +- 4 files changed, 156 insertions(+), 47 deletions(-) diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 04d552258..ea1130a0b 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -1,4 +1,3 @@ -use std::io::Write; use std::os::unix::fs::symlink; use std::path::Path; use std::{fs::create_dir_all, process::Command}; @@ -37,6 +36,7 @@ use crate::{ ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, SHARED_VAR_PATH, STATE_DIR_RELATIVE, }, + deploy::{ORIGIN_BOOTC, ORIGIN_SOURCE_IMAGE}, parsers::bls_config::BLSConfig, spec::ImageReference, spec::{FilesystemOverlay, FilesystemOverlayAccessMode, FilesystemOverlayPersistence}, @@ -64,6 +64,34 @@ pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result, + target: &ImageReference, +) -> Result { + let target = target.to_image_proxy_ref()?; + // The composefs pull path does not yet enforce the spec signature mode. + let origin = origin + .section("origin") + .item( + ORIGIN_CONTAINER, + format!("ostree-unverified-image:{target}"), + ) + .section(ORIGIN_BOOTC); + Ok(match source { + Some(source) => origin.item(ORIGIN_SOURCE_IMAGE, source.to_string()), + None => origin.erase(ORIGIN_SOURCE_IMAGE), + }) +} + +pub(crate) fn write_origin(sysroot: &Dir, deployment: &str, origin: &tini::Ini) -> Result<()> { + let state = sysroot.open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}"))?; + state + .atomic_write(format!("{deployment}.origin"), origin.to_string()) + .context("Writing deployment origin") +} + pub(crate) fn read_boot_type(sysroot: &Dir, deployment_id: &str) -> Result> { Sha512HashValue::from_hex(deployment_id).context("Invalid deployment digest")?; let Some(origin) = read_origin(sysroot, deployment_id)? else { @@ -198,15 +226,11 @@ fn add_update_in_origin( } state_dir - .atomic_replace_with(origin_filename, move |f| -> std::io::Result<_> { - f.write_all(ini.to_string().as_bytes())?; - f.flush()?; - - let perms = Permissions::from_mode(0o644); - f.get_mut().as_file_mut().set_permissions(perms)?; - - Ok(()) - }) + .atomic_write_with_perms( + origin_filename, + ini.to_string(), + Permissions::from_mode(0o644), + ) .context("Writing to origin file")?; Ok(()) @@ -236,7 +260,8 @@ pub(crate) fn update_boot_digest_in_origin( /// /// * `root_path` - The root filesystem path (typically `/sysroot`) /// * `deployment_id` - Unique SHA512 hash identifier for this deployment -/// * `imgref` - Container image reference for the deployment +/// * `target_imgref` - Container image reference for subsequent updates +/// * `source_imgref` - Optional reference used to obtain this image /// * `initialize_var` - Whether to populate the shared `/var` from the image /// * `boot_type` - Boot artifact type (`Bls`, `Uki`, or `Aboot`) /// * `boot_digest` - Optional boot digest for verification @@ -286,20 +311,11 @@ pub(crate) async fn write_composefs_state( allow_missing_fsverity, )?; - let imgref = target_imgref.to_image_proxy_ref()?; - let mut config = tini::Ini::new() - .section("origin") - // The composefs pull path does not yet enforce the spec signature mode. - .item( - ORIGIN_CONTAINER, - format!("ostree-unverified-image:{imgref}"), - ); - - if let Some(source) = source_imgref.filter(|source| *source != target_imgref) { - config = config - .section(crate::deploy::ORIGIN_BOOTC) - .item(crate::deploy::ORIGIN_SOURCE_IMAGE, source.to_string()); - } + let mut config = with_image_references( + tini::Ini::new(), + source_imgref.filter(|source| *source != target_imgref), + target_imgref, + )?; config = config .section(ORIGIN_KEY_BOOT) @@ -315,17 +331,8 @@ pub(crate) async fn write_composefs_state( .section(ORIGIN_KEY_IMAGE) .item(ORIGIN_KEY_MANIFEST_DIGEST, manifest_digest); - let state_dir = - Dir::open_ambient_dir(&state_path, ambient_authority()).context("Opening state dir")?; - - state_dir - .atomic_write( - format!("{}.origin", deployment_id.to_hex()), - config.to_string().as_bytes(), - ) - .context("Failed to write to .origin file")?; - - Ok(()) + let sysroot = Dir::open_ambient_dir(root_path, ambient_authority())?; + write_origin(&sysroot, &deployment_id.to_hex(), &config) } pub(crate) fn write_staged_deployment(staged: &StagedDeployment) -> Result<()> { diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index da803aa2f..d061eb894 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use fn_error_context::context; +use ostree_ext::oci_spec::image::Digest; use crate::{ bootc_composefs::{ @@ -119,21 +120,35 @@ pub(crate) async fn switch_composefs( // With a delta the target is whatever the delta says it is, and we can look // it up locally; without one we have to ask the registry. - let (image, manifest) = match &delta { + let (image, manifest, manifest_digest) = match &delta { Some(delta) => { crate::delta::reject_unified_storage(delta, do_upgrade_opts.use_unified)?; ensure_delta_source_present(repo, delta)?; ( lookup_config_splitstream(repo, delta.target_manifest().config().digest())?, delta.target_manifest().clone(), + delta.target_manifest_digest().to_string(), ) } None => { - let (image, img_config, _) = is_image_pulled(repo, target_imgref).await?; - (image, img_config.manifest) + let (image, img_config, digest) = is_image_pulled(repo, target_imgref).await?; + (image, img_config.manifest, digest) } }; + if final_target_imgref.is_some() + && switch_origin( + storage, + &host, + &source, + origin, + &manifest_digest.parse().context("Parsing manifest digest")?, + &do_upgrade_opts, + )? + { + return Ok(()); + } + if let Some(cfg_verity) = image { let action = validate_update( storage, @@ -178,3 +193,62 @@ pub(crate) async fn switch_composefs( Ok(()) } + +/// Reuse only an identical OCI image, never just an identical composefs root. +/// Different manifests can share a root while providing different initial /etc +/// content; those still go through do_upgrade's collision check. +fn switch_origin( + storage: &Storage, + host: &crate::spec::Host, + source: &crate::spec::ImageReference, + target: &crate::spec::ImageReference, + manifest_digest: &Digest, + opts: &DoUpgradeOpts<'_>, +) -> Result { + let same_image = |entry: &&crate::spec::BootEntry| { + entry + .image + .as_ref() + .is_some_and(|image| image.image_digest == manifest_digest.as_ref()) + }; + let entry = host.status.staged.as_ref().filter(same_image); + let Some(entry) = entry else { return Ok(false) }; + anyhow::ensure!( + opts.soft_reboot.is_none(), + "Origin-only changes require a full reboot" + ); + anyhow::ensure!( + opts.uki_addon_opts.scoped.is_none() && opts.uki_addon_opts.global.is_none(), + "Cannot install UKI addons with an origin-only change" + ); + anyhow::ensure!( + !host.status.rollback_queued, + "Cannot change origin while rollback is queued" + ); + let locked = entry.download_only || opts.download_only; + anyhow::ensure!( + !opts.apply || !locked, + "Staged deployment is download-only; use bootc switch --from-downloaded --apply" + ); + let deployment = &entry.require_composefs()?.verity; + super::service::start_finalize_stated_svc()?; + // This is a genuine staged deployment. Do not rewrite state also used + // by a rollback or pinned deployment. + anyhow::ensure!( + host.all_composefs_deployments()?.iter().all(|d| { + d.deployment.verity != *deployment + || (matches!(d.ty, Some(crate::status::Slot::Staged)) && !d.pinned) + }), + "Origin change would modify another deployment" + ); + super::update::set_staged_lock(storage, entry, locked)?; + let old = super::state::read_origin(&storage.physical_root, deployment)? + .context("No staged origin")?; + let origin = super::state::with_image_references(old, Some(source), target)?; + super::state::write_origin(&storage.physical_root, deployment, &origin)?; + println!("Staged update origin: {target}"); + if opts.apply { + crate::reboot::reboot()?; + } + Ok(true) +} diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 243fd6194..2597dfaae 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -561,9 +561,35 @@ pub(crate) async fn apply_upgrade_from_downloaded( } let staged_id = &staged.require_composefs()?.verity; - if host.require_composefs_booted()?.boot_type == BootType::Aboot { + set_staged_lock(storage, staged, false)?; + + start_finalize_stated_svc()?; + + return apply_upgrade(storage, composefs, staged_id, &do_upgrade_opts).await; +} + +pub(crate) fn set_staged_lock( + storage: &Storage, + staged: &crate::spec::BootEntry, + locked: bool, +) -> Result<()> { + let staged_cfs = staged.require_composefs()?; + if staged_cfs.boot_type == BootType::Aboot { let mut state = aboot::AbootState::open(&storage.physical_root)?; - let pending = state.unlock_pending(staged_id)?; + let pending = if locked { + let mut pending = state + .read_pending()? + .context("No pending aboot deployment")?; + anyhow::ensure!( + pending.depl_id == staged_cfs.verity, + "Pending aboot deployment changed" + ); + pending.finalization_locked = true; + state.write_pending(&pending)?; + pending + } else { + state.unlock_pending(&staged_cfs.verity)? + }; write_staged_deployment(&pending.staged())?; } else { let staged_depl_dir = @@ -574,13 +600,14 @@ pub(crate) async fn apply_upgrade_from_downloaded( .context("Reading staged file")?; let mut new_staged: StagedDeployment = serde_json::from_str(¤t).context("Deserializing staged file")?; - new_staged.finalization_locked = false; + anyhow::ensure!( + new_staged.depl_id == staged_cfs.verity, + "Staged deployment changed" + ); + new_staged.finalization_locked = locked; write_staged_deployment(&new_staged)?; } - - start_finalize_stated_svc()?; - - return apply_upgrade(storage, composefs, staged_id, &do_upgrade_opts).await; + Ok(()) } #[context("Upgrading composefs")] diff --git a/tmt/tests/booted/test-switch-target-imgref.nu b/tmt/tests/booted/test-switch-target-imgref.nu index 29367d09f..9e7cd596d 100644 --- a/tmt/tests/booted/test-switch-target-imgref.nu +++ b/tmt/tests/booted/test-switch-target-imgref.nu @@ -48,7 +48,8 @@ insecure = true podman save --format oci-archive -o $archive $source podman stop $registry - # A new source must still be processed when the update origin is unchanged. + # Re-origin an identical staged image. Also exercises OSTree cache retention. + bootc switch --download-only --transport containers-storage localhost/target-imgref-source:v1 bootc switch --download-only --transport containers-storage --target-imgref $origin localhost/target-imgref-source:v1 let previous = (status).status.staged.image.imageDigest # A new source must still be processed when the update origin is unchanged. From e5e0434eb372eb986ac73b8e137b35d1a152d2d3 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 15:01:14 +0530 Subject: [PATCH 07/10] composefs: Stage booted origin changes and preserve rollback Changing the origin of the running image should not replace the OS rollback deployment. Stage a durable origin change and apply it during finalization, validating the image and previous origin before writing. Keep download-only state across reboot and integrate cancellation with deployment deletion, rollback, and replacement. Cover recovery and the activation lifecycle with unit and reboot tests. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/delete.rs | 8 + crates/lib/src/bootc_composefs/finalize.rs | 5 + crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/origin.rs | 451 ++++++++++++++++++ crates/lib/src/bootc_composefs/rollback.rs | 9 +- crates/lib/src/bootc_composefs/soft_reboot.rs | 34 +- crates/lib/src/bootc_composefs/status.rs | 7 +- crates/lib/src/bootc_composefs/switch.rs | 83 ++-- .../lib/src/bootc_composefs/uki_addons_cli.rs | 9 + crates/lib/src/bootc_composefs/update.rs | 42 +- crates/lib/src/generator.rs | 43 ++ tmt/tests/booted/test-switch-target-imgref.nu | 53 ++ 12 files changed, 706 insertions(+), 39 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/origin.rs diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 955c32f20..6413c17fc 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -223,6 +223,14 @@ pub(crate) async fn delete_composefs_deployment( deployment_id ); + if super::origin::OriginChange::read(&storage.physical_root)? + .is_some_and(|pending| pending.deployment == deployment_id) + { + super::origin::clear_pending_origin_change(&storage.physical_root)?; + println!("Cancelled pending origin change; kept the OS deployment"); + return Ok(()); + } + let host = get_composefs_status(storage, booted_cfs).await?; let booted = host.require_composefs_booted()?; diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 5e686bc3b..320eca70e 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -93,6 +93,11 @@ pub(crate) async fn composefs_backend_finalize( return Ok(()); } + if let Some(pending) = super::origin::OriginChange::read_active(&storage.physical_root, &host)? + { + return pending.finalize(&storage.physical_root, &host); + } + let staged_composefs = staged_depl.composefs.as_ref().ok_or(anyhow::anyhow!( "Staged deployment is not a composefs deployment" ))?; diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index e6d6f8787..269f6660f 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod export; pub(crate) mod finalize; pub(crate) mod gc; pub(crate) mod image; +pub(crate) mod origin; pub(crate) mod progress; pub(crate) mod repo; pub(crate) mod rollback; diff --git a/crates/lib/src/bootc_composefs/origin.rs b/crates/lib/src/bootc_composefs/origin.rs new file mode 100644 index 000000000..3817f58be --- /dev/null +++ b/crates/lib/src/bootc_composefs/origin.rs @@ -0,0 +1,451 @@ +//! A pending origin change for the booted image, without a second OS deployment. +//! +//! The OCI manifest must match exactly. Finalization changes only the origin; +//! the existing /etc, boot artifacts, and OS rollback deployment are retained. + +use anyhow::{Context, Result, ensure}; +use canon_json::CanonJsonSerialize; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use fn_error_context::context; +use ostree_ext::oci_spec::image::Digest; +use rustix::fs::fsync; +use serde::{Deserialize, Serialize}; + +use super::state::{read_origin, with_image_references, write_origin}; +use crate::spec::{Host, ImageReference}; + +pub(crate) const PENDING_ORIGIN: &str = "state/bootc-origin-change.json"; + +#[context("Checking whether an origin change should reboot")] +pub(crate) fn should_reboot( + soft_reboot: Option, + apply: bool, + locked: bool, +) -> Result { + ensure!( + !apply || !locked, + "Staged deployment is download-only; use --from-downloaded --apply" + ); + if let Some(mode) = soft_reboot { + ensure!( + ostree_ext::systemd_has_soft_reboot(), + "System does not support soft reboots" + ); + super::soft_reboot::check_capability(false, mode)?; + } + Ok(apply && soft_reboot.is_none()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct OriginChange { + pub(crate) deployment: String, + manifest_digest: Digest, + previous_origin: String, + pub(crate) source: ImageReference, + target: ImageReference, + pub(crate) finalization_locked: bool, +} + +impl OriginChange { + #[context("Creating pending origin change")] + pub(crate) fn new( + sysroot: &Dir, + host: &Host, + manifest_digest: &Digest, + source: &ImageReference, + target: &ImageReference, + finalization_locked: bool, + ) -> Result { + let booted = host + .status + .booted + .as_ref() + .context("No booted deployment")?; + let deployment = booted.require_composefs()?.verity.clone(); + let previous_origin = read_origin(sysroot, &deployment)? + .context("No booted origin")? + .to_string(); + let pending = Self { + deployment, + manifest_digest: manifest_digest.clone(), + previous_origin, + source: source.clone(), + target: target.clone(), + finalization_locked, + }; + pending.validate(sysroot, host)?; + Ok(pending) + } + + #[context("Reading pending origin change")] + pub(crate) fn read(sysroot: &Dir) -> Result> { + let Some(contents) = sysroot.read_optional(PENDING_ORIGIN)? else { + return Ok(None); + }; + let pending: Self = serde_json::from_slice(&contents)?; + Sha512HashValue::from_hex(&pending.deployment) + .context("Invalid origin-change deployment")?; + Ok(Some(pending)) + } + + #[context("Writing pending origin change")] + pub(crate) fn write(&self, sysroot: &Dir) -> Result<()> { + sysroot.atomic_write(PENDING_ORIGIN, self.to_canon_json_vec()?)?; + Ok(()) + } + + /// Stale metadata must not hide real deployments or prevent their activation. + /// Keep it on disk for explicit cancellation with delete-deployment. + #[context("Finding applicable origin change")] + pub(crate) fn read_active(sysroot: &Dir, host: &Host) -> Result> { + let Some(pending) = Self::read(sysroot)? else { + return Ok(None); + }; + if !pending.is_applicable(sysroot, host)? { + tracing::warn!( + "Ignoring stale origin change; cancel it with bootc delete-deployment {}", + pending.deployment + ); + return Ok(None); + } + Ok(Some(pending)) + } + + #[context("Building updated deployment origin")] + fn desired_origin(&self) -> Result { + let previous = + tini::Ini::from_string(&self.previous_origin).context("Parsing previous origin")?; + with_image_references(previous, Some(&self.source), &self.target) + } + + #[context("Checking whether pending origin change applies to the booted deployment")] + fn is_applicable(&self, sysroot: &Dir, host: &Host) -> Result { + let booted = host + .status + .booted + .as_ref() + .context("No booted deployment")?; + // This record survives reboot; the user may boot a rollback entry + // instead of the deployment for which the origin change was staged. + if booted.require_composefs()?.verity != self.deployment + || !booted + .image + .as_ref() + .is_some_and(|image| image.image_digest == self.manifest_digest.as_ref()) + || host.status.rollback_queued + || !host.status.staged.as_ref().is_none_or(|entry| { + entry + .composefs + .as_ref() + .is_some_and(|c| c.verity == self.deployment) + }) + { + return Ok(false); + } + let Some(current) = read_origin(sysroot, &self.deployment)? else { + return Ok(false); + }; + let current = current.to_string(); + // The desired value is valid after interruption between the origin write + // and removing the transaction. Retrying finalization is then idempotent. + Ok(current == self.previous_origin || current == self.desired_origin()?.to_string()) + } + + #[context("Validating pending origin change")] + pub(crate) fn validate(&self, sysroot: &Dir, host: &Host) -> Result<()> { + ensure!( + self.is_applicable(sysroot, host)?, + "Pending origin change no longer matches the deployment; cancel it with delete-deployment" + ); + Ok(()) + } + + #[context("Showing pending origin change in status")] + pub(crate) fn project(&self, sysroot: &Dir, host: &mut Host) -> Result<()> { + self.validate(sysroot, host)?; + let mut staged = host.status.booted.clone().context("No booted deployment")?; + staged.image.as_mut().context("No booted image")?.image = self.target.clone(); + staged.cached_update = None; + staged.download_only = self.finalization_locked; + staged.soft_reboot_capable = false; + host.status.staged = Some(staged); + host.spec.image = Some(self.target.clone()); + Ok(()) + } + + #[context("Finalizing pending origin change")] + pub(crate) fn finalize(&self, sysroot: &Dir, host: &Host) -> Result<()> { + self.validate(sysroot, host)?; + ensure!( + !self.finalization_locked, + "Pending origin change is download-only" + ); + write_origin(sysroot, &self.deployment, &self.desired_origin()?)?; + clear_pending_origin_change(sysroot) + } +} + +#[context("Clearing pending origin change")] +pub(crate) fn clear_pending_origin_change(sysroot: &Dir) -> Result<()> { + if sysroot.remove_file_optional(PENDING_ORIGIN)? { + fsync(sysroot.open_dir("state")?.reopen_as_ownedfd()?) + .context("Syncing pending origin change removal")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootc_composefs::boot::BootType; + use crate::composefs_consts::STATE_DIR_RELATIVE; + use crate::deploy::{ORIGIN_BOOTC, ORIGIN_SOURCE_IMAGE}; + use crate::spec::{BootEntry, BootEntryComposefs, Bootloader, HostSpec, ImageStatus}; + use ostree_ext::container::deploy::ORIGIN_CONTAINER; + + fn fixture() -> Result<(cap_std_ext::cap_tempfile::TempDir, Host, ImageReference)> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std_ext::cap_std::ambient_authority())?; + let deployment = "ab".repeat(64); + let source = ImageReference { + image: "localhost/os:v1".into(), + transport: "containers-storage".into(), + signature: None, + }; + let mut host = Host::new(HostSpec { + image: Some(source.clone()), + ..Default::default() + }); + host.status.booted = Some(BootEntry { + image: Some(ImageStatus { + image: source.clone(), + version: None, + timestamp: None, + image_digest: format!("sha256:{}", "cd".repeat(32)), + architecture: "amd64".into(), + }), + cached_update: None, + incompatible: false, + pinned: false, + soft_reboot_capable: false, + download_only: false, + store: None, + ostree: None, + composefs: Some(BootEntryComposefs { + verity: deployment.clone(), + boot_type: BootType::Uki, + bootloader: Bootloader::None, + boot_digest: Some("boot-digest".into()), + missing_verity_allowed: false, + }), + }); + let mut rollback = host.status.booted.clone().unwrap(); + rollback.composefs.as_mut().unwrap().verity = "ef".repeat(64); + host.status.rollback = Some(rollback); + root.create_dir_all(format!("{STATE_DIR_RELATIVE}/{deployment}"))?; + let origin = tini::Ini::new() + .section("origin") + .item(ORIGIN_CONTAINER, source.to_string()) + .section("boot") + .item("digest", "boot-digest") + .section("custom") + .item("keep", "yes"); + write_origin(&root, &deployment, &origin)?; + let target = ImageReference { + image: "quay.io/example/os:v1".into(), + transport: "registry".into(), + signature: None, + }; + Ok((root, host, target)) + } + + #[test] + fn origin_change_lifecycle() -> Result<()> { + let (root, host, target) = fixture()?; + let image = host.status.booted.as_ref().unwrap().image.as_ref().unwrap(); + let pending = + OriginChange::new(&root, &host, &image.digest()?, &image.image, &target, true)?; + let before = read_origin(&root, &pending.deployment)? + .unwrap() + .to_string(); + pending.write(&root)?; + + // Re-read from durable state, as after a reboot with /run cleared. + let mut pending = OriginChange::read(&root)?.unwrap(); + let mut projected = host.clone(); + pending.project(&root, &mut projected)?; + assert_eq!(projected.status.booted, host.status.booted); + assert_eq!(projected.status.rollback, host.status.rollback); + assert_eq!(projected.spec.image.as_ref(), Some(&target)); + assert!(projected.status.staged.as_ref().unwrap().download_only); + assert!(pending.finalize(&root, &projected).is_err()); + assert_eq!( + read_origin(&root, &pending.deployment)? + .unwrap() + .to_string(), + before + ); + + pending.finalization_locked = false; + pending.write(&root)?; + // Simulate interruption after replacing the origin but before consuming + // the transaction; the next finalization must finish safely. + write_origin(&root, &pending.deployment, &pending.desired_origin()?)?; + let pending = OriginChange::read(&root)?.unwrap(); + pending.finalize(&root, &projected)?; + assert!(OriginChange::read(&root)?.is_none()); + let origin = read_origin(&root, &pending.deployment)?.unwrap(); + assert_eq!( + origin.get::("custom", "keep").as_deref(), + Some("yes") + ); + assert_eq!( + origin.get::("boot", "digest").as_deref(), + Some("boot-digest") + ); + assert_eq!( + origin.get::(ORIGIN_BOOTC, ORIGIN_SOURCE_IMAGE), + Some(image.image.to_string()) + ); + let recorded: ostree_ext::container::OstreeImageReference = origin + .get::("origin", ORIGIN_CONTAINER) + .unwrap() + .as_str() + .try_into()?; + assert_eq!(ImageReference::from(recorded), target); + Ok(()) + } + + #[test] + fn origin_change_metadata_compatibility() -> Result<()> { + let (root, host, target) = fixture()?; + let image = host.status.booted.as_ref().unwrap().image.as_ref().unwrap(); + // The persisted format uses strings, including when written before the + // manifest digest became typed. Keep accepting that representation. + let metadata = serde_json::json!({ + "deployment": host.require_composefs_booted()?.verity, + "manifest_digest": image.image_digest, + "previous_origin": "[origin]\ncontainer-image-reference=old\n", + "source": image.image, + "target": target, + "finalization_locked": true, + }); + root.write(PENDING_ORIGIN, serde_json::to_vec(&metadata)?)?; + let pending = OriginChange::read(&root)?.unwrap(); + assert_eq!(serde_json::to_value(&pending)?, metadata); + for (key, invalid) in [ + ("manifest_digest", "not-a-digest"), + ("manifest_digest", "sha256:1234"), + ("deployment", "../another-deployment"), + ] { + let mut invalid_metadata = metadata.clone(); + invalid_metadata[key] = invalid.into(); + root.write(PENDING_ORIGIN, serde_json::to_vec(&invalid_metadata)?)?; + assert!(OriginChange::read(&root).is_err(), "{key}: {invalid}"); + } + Ok(()) + } + + #[test] + fn origin_references_preserve_metadata() -> Result<()> { + let (root, host, target) = fixture()?; + let source = host.spec.image.as_ref().unwrap(); + let previous = read_origin(&root, &host.require_composefs_booted()?.verity)? + .unwrap() + .to_string(); + for source in [None, Some(source), Some(&target)] { + let old = tini::Ini::from_string(&previous)? + .section(ORIGIN_BOOTC) + .item(ORIGIN_SOURCE_IMAGE, "stale"); + let updated = with_image_references(old, source, &target)?; + assert_eq!( + updated.get::("custom", "keep").as_deref(), + Some("yes") + ); + assert_eq!( + updated.get::("boot", "digest").as_deref(), + Some("boot-digest") + ); + assert_eq!( + updated.get::(ORIGIN_BOOTC, ORIGIN_SOURCE_IMAGE), + source.map(ToString::to_string) + ); + let recorded: ostree_ext::container::OstreeImageReference = updated + .get::("origin", ORIGIN_CONTAINER) + .unwrap() + .as_str() + .try_into()?; + assert_eq!(ImageReference::from(recorded), target); + } + Ok(()) + } + + #[test] + fn origin_change_rejects_stale_state_and_cancels() -> Result<()> { + for case in [ + "manifest", + "deployment", + "staged", + "rollback", + "origin", + "missing-origin", + ] { + let (root, mut host, target) = fixture()?; + let image = host.status.booted.as_ref().unwrap().image.as_ref().unwrap(); + let pending = + OriginChange::new(&root, &host, &image.digest()?, &image.image, &target, false)?; + pending.write(&root)?; + match case { + "manifest" => { + host.status + .booted + .as_mut() + .unwrap() + .image + .as_mut() + .unwrap() + .image_digest = "sha256:different".into() + } + "deployment" => { + host.status + .booted + .as_mut() + .unwrap() + .composefs + .as_mut() + .unwrap() + .verity = "12".repeat(64) + } + "staged" => host.status.staged = host.status.rollback.clone(), + "rollback" => host.status.rollback_queued = true, + "origin" => write_origin( + &root, + &pending.deployment, + &tini::Ini::new().section("custom").item("changed", "yes"), + )?, + "missing-origin" => root.remove_file(format!( + "{STATE_DIR_RELATIVE}/{0}/{0}.origin", + pending.deployment + ))?, + _ => unreachable!(), + } + let before_host = host.clone(); + if let Some(active) = OriginChange::read_active(&root, &host)? { + active.project(&root, &mut host)?; + panic!("Stale state must not be projected: {case}"); + } + assert_eq!(host, before_host); + assert!(pending.finalize(&root, &host).is_err(), "{case}"); + assert!(OriginChange::read(&root)?.is_some()); + let before = read_origin(&root, &pending.deployment)?.map(|origin| origin.to_string()); + clear_pending_origin_change(&root)?; + clear_pending_origin_change(&root)?; + assert!(OriginChange::read(&root)?.is_none()); + assert_eq!( + read_origin(&root, &pending.deployment)?.map(|origin| origin.to_string()), + before + ); + } + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 37d7ed4ef..14a36640d 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -225,7 +225,12 @@ pub(crate) async fn composefs_rollback( "Starting composefs rollback operation" ); - let host = get_composefs_status(storage, booted_cfs).await?; + let mut host = get_composefs_status(storage, booted_cfs).await?; + if super::origin::OriginChange::read_active(&storage.physical_root, &host)?.is_some() { + // The projected metadata change has no staged bootloader entries or + // /run marker for the ordinary rollback cleanup to remove. + host.status.staged = None; + } let new_spec = { let mut new_spec = host.spec.clone(); @@ -301,6 +306,8 @@ pub(crate) async fn composefs_rollback( } } + super::origin::clear_pending_origin_change(&storage.physical_root)?; + if reverting { println!("Next boot: current deployment"); } else { diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 2bfd3317c..260b514f6 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -22,6 +22,30 @@ use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, proc const NEXTROOT: &str = "/run/nextroot"; +/// Auto mode leaves an incapable deployment staged; required mode must fail. +pub(super) fn check_capability(capable: bool, mode: SoftRebootMode) -> Result { + anyhow::ensure!( + capable || matches!(mode, SoftRebootMode::Auto), + "Deployment is not soft-reboot capable" + ); + Ok(capable) +} + +#[test] +fn test_check_capability() { + for (capable, mode, expected) in [ + (false, SoftRebootMode::Auto, Some(false)), + (false, SoftRebootMode::Required, None), + (true, SoftRebootMode::Auto, Some(true)), + (true, SoftRebootMode::Required, Some(true)), + ] { + match expected { + Some(expected) => assert_eq!(check_capability(capable, mode).unwrap(), expected), + None => assert!(check_capability(capable, mode).is_err()), + } + } +} + #[context("Resetting soft reboot state")] pub(crate) fn reset_soft_reboot() -> Result<()> { // NOTE: By default bootc runs in an unshared mount namespace; @@ -91,14 +115,8 @@ pub(crate) async fn prepare_soft_reboot_composefs( .find(|entry| entry.deployment.verity == *deployment_id) .ok_or_else(|| anyhow::anyhow!("Deployment '{deployment_id}' not found"))?; - if !required_deployment.soft_reboot_capable { - match soft_reboot_mode { - SoftRebootMode::Required => { - anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state") - } - - SoftRebootMode::Auto => return Ok(()), - } + if !check_capability(required_deployment.soft_reboot_capable, soft_reboot_mode)? { + return Ok(()); } start_finalize_stated_svc()?; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe..66725d541 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -705,7 +705,12 @@ pub(crate) async fn get_composefs_status( storage: &crate::store::Storage, booted_cfs: &crate::store::BootedComposefs, ) -> Result { - composefs_deployment_status_from(&storage, booted_cfs.cmdline).await + let mut host = composefs_deployment_status_from(&storage, booted_cfs.cmdline).await?; + if let Some(pending) = super::origin::OriginChange::read_active(&storage.physical_root, &host)? + { + pending.project(&storage.physical_root, &mut host)?; + } + Ok(host) } /// Check whether any deployment is capable of being soft rebooted or not diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index d061eb894..716134b29 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -70,16 +70,30 @@ pub(crate) async fn switch_composefs( let current_origin = super::state::read_origin(&storage.physical_root, ¤t.require_composefs()?.verity)? .context("No origin for current deployment")?; - let previous_source = current_origin.get::( - crate::deploy::ORIGIN_BOOTC, - crate::deploy::ORIGIN_SOURCE_IMAGE, - ); + let pending = super::origin::OriginChange::read_active(&storage.physical_root, &host)?; + let previous_source = pending.as_ref().map(|p| p.source.to_string()).or_else(|| { + current_origin.get::( + crate::deploy::ORIGIN_BOOTC, + crate::deploy::ORIGIN_SOURCE_IMAGE, + ) + }); if switch_images_unchanged( &source, final_target_imgref.as_ref(), host.spec.image.as_ref(), previous_source.as_deref(), )? { + if let Some(pending) = pending { + if super::origin::should_reboot( + opts.soft_reboot, + opts.apply, + pending.finalization_locked, + )? { + crate::reboot::reboot()?; + } + println!("Image specification is unchanged."); + return Ok(()); + } return handle_unchanged_switch(&opts, &host); } @@ -211,12 +225,13 @@ fn switch_origin( .as_ref() .is_some_and(|image| image.image_digest == manifest_digest.as_ref()) }; - let entry = host.status.staged.as_ref().filter(same_image); + let entry = host + .status + .staged + .as_ref() + .filter(same_image) + .or_else(|| host.status.booted.as_ref().filter(same_image)); let Some(entry) = entry else { return Ok(false) }; - anyhow::ensure!( - opts.soft_reboot.is_none(), - "Origin-only changes require a full reboot" - ); anyhow::ensure!( opts.uki_addon_opts.scoped.is_none() && opts.uki_addon_opts.global.is_none(), "Cannot install UKI addons with an origin-only change" @@ -226,28 +241,40 @@ fn switch_origin( "Cannot change origin while rollback is queued" ); let locked = entry.download_only || opts.download_only; - anyhow::ensure!( - !opts.apply || !locked, - "Staged deployment is download-only; use bootc switch --from-downloaded --apply" - ); + let reboot = super::origin::should_reboot(opts.soft_reboot, opts.apply, locked)?; let deployment = &entry.require_composefs()?.verity; super::service::start_finalize_stated_svc()?; - // This is a genuine staged deployment. Do not rewrite state also used - // by a rollback or pinned deployment. - anyhow::ensure!( - host.all_composefs_deployments()?.iter().all(|d| { - d.deployment.verity != *deployment - || (matches!(d.ty, Some(crate::status::Slot::Staged)) && !d.pinned) - }), - "Origin change would modify another deployment" - ); - super::update::set_staged_lock(storage, entry, locked)?; - let old = super::state::read_origin(&storage.physical_root, deployment)? - .context("No staged origin")?; - let origin = super::state::with_image_references(old, Some(source), target)?; - super::state::write_origin(&storage.physical_root, deployment, &origin)?; + if deployment == &host.require_composefs_booted()?.verity { + if host.require_composefs_booted()?.boot_type == super::boot::BootType::Aboot { + super::update::ensure_no_aboot_attempt(storage)?; + } + let pending = super::origin::OriginChange::new( + &storage.physical_root, + host, + manifest_digest, + source, + target, + locked, + )?; + pending.write(&storage.physical_root)?; + } else { + // This is a genuine staged deployment. Do not rewrite state also used + // by a rollback or pinned deployment. + anyhow::ensure!( + host.all_composefs_deployments()?.iter().all(|d| { + d.deployment.verity != *deployment + || (matches!(d.ty, Some(crate::status::Slot::Staged)) && !d.pinned) + }), + "Origin change would modify another deployment" + ); + super::update::set_staged_lock(storage, entry, locked)?; + let old = super::state::read_origin(&storage.physical_root, deployment)? + .context("No staged origin")?; + let origin = super::state::with_image_references(old, Some(source), target)?; + super::state::write_origin(&storage.physical_root, deployment, &origin)?; + } println!("Staged update origin: {target}"); - if opts.apply { + if reboot { crate::reboot::reboot()?; } Ok(true) diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index 05c93fcc1..c049c42a6 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -102,6 +102,15 @@ pub(crate) fn handle_addon_cli_cmd( host: &Host, opts: &UkiAddonCliOpts, ) -> Result<()> { + if matches!( + opts, + UkiAddonCliOpts::Add { .. } | UkiAddonCliOpts::Remove { .. } + ) { + anyhow::ensure!( + super::origin::OriginChange::read_active(&storage.physical_root, host)?.is_none(), + "Cannot modify UKI addons while an origin change is pending; activate or cancel it first" + ); + } let Ok(esp) = storage.require_esp() else { anyhow::bail!("ESP not found"); }; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 2597dfaae..263e3efee 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -257,7 +257,7 @@ pub(crate) fn validate_update( Ok(UpdateAction::Proceed) } -fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> { +pub(crate) fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> { let state = aboot::AbootState::open(&storage.physical_root)?; anyhow::ensure!( state.read_attempted()?.is_none(), @@ -327,6 +327,14 @@ pub(crate) async fn do_upgrade( ensure_no_aboot_attempt(storage)?; } + // A pending origin change is not another OS deployment. Boot setup must + // retain the real booted/rollback entries when replacing it with new content. + let mut host = host.clone(); + if super::origin::OriginChange::read_active(&storage.physical_root, &host)?.is_some() { + host.status.staged = None; + } + let host = &host; + // Pre-flight disk space check before pulling. crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?; @@ -487,6 +495,7 @@ pub(crate) async fn do_upgrade( state.write_pending(&pending)?; } write_staged_deployment(&staged_state)?; + super::origin::clear_pending_origin_change(&storage.physical_root)?; // We take into account the staged bootloader entries so this won't remove // the currently staged entry @@ -547,6 +556,24 @@ pub(crate) async fn apply_upgrade_from_downloaded( host: &Host, do_upgrade_opts: &DoUpgradeOpts<'_>, ) -> Result<()> { + if let Some(mut pending) = + super::origin::OriginChange::read_active(&storage.physical_root, host)? + { + let reboot = super::origin::should_reboot( + do_upgrade_opts.soft_reboot, + do_upgrade_opts.apply, + false, + )?; + pending.validate(&storage.physical_root, host)?; + start_finalize_stated_svc()?; + pending.finalization_locked = false; + pending.write(&storage.physical_root)?; + if reboot { + crate::reboot::reboot()?; + } + return Ok(()); + } + let staged = host .status .staged @@ -713,6 +740,19 @@ pub(crate) async fn upgrade_composefs( // We have a staged image and it has the same digest as the currently booted image's latest // digest if staged_image.image_digest == manifest_digest { + if let Some(pending) = + super::origin::OriginChange::read_active(&storage.physical_root, &host)? + { + if super::origin::should_reboot( + opts.soft_reboot, + opts.apply, + pending.finalization_locked, + )? { + crate::reboot::reboot()?; + } + println!("Update origin already staged."); + return Ok(()); + } if opts.apply { return crate::reboot::reboot(); } diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index dba8485b2..e064857de 100644 --- a/crates/lib/src/generator.rs +++ b/crates/lib/src/generator.rs @@ -189,6 +189,10 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { aboot_generator_impl(root, unit_dir)?; + if is_composefs && !is_ostree { + origin_change_generator_impl(root, unit_dir)?; + } + // === Ostree-specific generator logic === // Only run on ostree systems (native composefs boots skip below). if !root.try_exists(OSTREE_BOOTED)? { @@ -246,6 +250,22 @@ pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result Result { + let Some(sysroot) = root.open_dir_optional("sysroot")? else { + return Ok(false); + }; + if !sysroot.try_exists(crate::bootc_composefs::origin::PENDING_ORIGIN)? { + return Ok(false); + } + // Re-arm even locked changes: finalization checks their persistent lock. + enable_unit( + unit_dir, + crate::composefs_consts::BOOTC_FINALIZE_STAGED_SERVICE, + MULTI_USER_TARGET, + )?; + Ok(true) +} + fn aboot_generator_impl(root: &Dir, unit_dir: &Dir) -> Result { let Some(cmdline) = root.read_to_string_optional("proc/cmdline")? else { return Ok(false); @@ -370,6 +390,29 @@ mod tests { use super::*; + #[test] + fn origin_change_rearms_finalization() -> Result<()> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + let units = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert!(!origin_change_generator_impl(&root, &units)?); + root.create_dir_all("sysroot/state")?; + root.write( + format!("sysroot/{}", crate::bootc_composefs::origin::PENDING_ORIGIN), + b"{}", + )?; + assert!(origin_change_generator_impl(&root, &units)?); + let link = format!( + "{MULTI_USER_TARGET}.wants/{}", + crate::composefs_consts::BOOTC_FINALIZE_STAGED_SERVICE + ); + assert_eq!( + units.read_link_contents(link)?, + std::path::Path::new("/usr/lib/systemd/system") + .join(crate::composefs_consts::BOOTC_FINALIZE_STAGED_SERVICE) + ); + Ok(()) + } + fn fixture() -> Result { let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; tempdir.create_dir("etc")?; diff --git a/tmt/tests/booted/test-switch-target-imgref.nu b/tmt/tests/booted/test-switch-target-imgref.nu index 9e7cd596d..0b4404074 100644 --- a/tmt/tests/booted/test-switch-target-imgref.nu +++ b/tmt/tests/booted/test-switch-target-imgref.nu @@ -84,6 +84,57 @@ def second_boot [] { assert equal (status).status.booted $booted assert equal (status).status.staged null + # Recover an identical booted image with a different future origin. Keep + # the registry unavailable throughout staging and the locked reboot. + podman load -i $archive + bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source + if (tap is_composefs) { + let id = $booted.composefs.verity + bootc delete-deployment $id + assert equal (status).status.staged null + assert equal (status).status.booted $booted + bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source + bootc rollback + assert equal (status).status.staged null + bootc rollback + bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source + } + assert equal (status).status.booted $booted + assert equal (status).status.staged.image.imageDigest $booted.image.imageDigest + assert equal (status).status.staged.image.image.image $next_origin + assert (status).status.staged.downloadOnly + podman rmi $source + bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source + tmt-reboot +} + +def third_boot [] { + assert equal (status).status.booted.image.image.image $origin + if not (tap is_composefs) { + # libostree discards an unfinalized staged deployment across reboot. + # Restore it from the offline delivery before explicitly unlocking it. + podman load -i $archive + bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source + podman rmi $source + } + assert equal (status).status.staged.image.image.image $next_origin + assert (status).status.staged.downloadOnly + bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source + if (tap is_composefs) { + let applied = do { bootc update --apply } | complete + assert ($applied.exit_code != 0) + } + bootc switch --from-downloaded + assert (not (status).status.staged.downloadOnly) + tmt-reboot +} + +def fourth_boot [] { + let booted = (status).status.booted + assert equal $booted.image.image.image $next_origin + assert equal (status).status.staged null + assert equal (open $marker | str trim) "v2" + bootc switch --transport containers-storage --target-imgref $next_origin $source podman rm $registry tap ok } @@ -92,6 +143,8 @@ def main [] { match $env.TMT_REBOOT_COUNT? { null | "0" => first_boot, "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, $n => { error make { msg: $"Invalid reboot count ($n)" } }, } } From bdc0ecc88eab940af535597a0b77b6d510a8be3d Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 15:01:15 +0530 Subject: [PATCH 08/10] tests: Verify updates after redirected switches Recording an origin is only useful if later updates actually use it. Extend the reboot test to check unchanged content and a newer image at the recorded registry after completing an offline delivery. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- tmt/tests/booted/test-switch-target-imgref.nu | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/tmt/tests/booted/test-switch-target-imgref.nu b/tmt/tests/booted/test-switch-target-imgref.nu index 0b4404074..3cb74811c 100644 --- a/tmt/tests/booted/test-switch-target-imgref.nu +++ b/tmt/tests/booted/test-switch-target-imgref.nu @@ -64,6 +64,7 @@ insecure = true # must use deployment metadata without contacting either image location. podman rmi $source stage + bootc switch $origin assert equal (status).status.staged $staged let applied = do { bootc switch --apply --transport containers-storage --target-imgref $origin $source } | complete assert ($applied.exit_code != 0) "--apply must not bypass a download-only lock" @@ -76,11 +77,13 @@ insecure = true def second_boot [] { let booted = (status).status.booted + let rollback = (status).status.rollback assert equal $booted.image.image.image $origin assert equal (open $marker | str trim) "v2" # Provenance survives reboot. The source remains absent and registry stopped. bootc switch --transport containers-storage --target-imgref $origin $source + bootc switch $origin assert equal (status).status.booted $booted assert equal (status).status.staged null @@ -89,6 +92,36 @@ def second_boot [] { podman load -i $archive bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source if (tap is_composefs) { + let staged = (status).status.staged + assert equal (status).status.rollback $rollback + if ($booted.composefs.bootType | str downcase) == "uki" { + for command in ["add" "remove"] { + let result = do { bootc uki-addon $command nonexistent global } | complete + assert ($result.exit_code != 0) + assert ($result.stderr | str contains "origin change is pending") + } + } + + cd (mktemp -d) + # A different OCI manifest with the same filesystem is still rejected. + $"FROM ($source)\nLABEL bootc.test.origin-collision=true\n" | save Containerfile + podman build -t localhost/target-imgref-collision . + let collision = do { bootc switch --transport containers-storage --target-imgref $origin localhost/target-imgref-collision } | complete + assert ($collision.exit_code != 0) + assert ($collision.stderr | str contains "same fs-verity digest") + assert equal (status).status.staged $staged + + # Failure after pulling must preserve the previously staged origin change. + "local" | save /etc/target-imgref-conflict + let containerfile = $"FROM ($source) as base\nRUN mkdir /etc/target-imgref-conflict && echo changed > ($marker)\n" + (tap make_uki_containerfile $containerfile) | save --force Containerfile + podman build -t localhost/target-imgref-conflict . + let conflict = do { bootc switch --soft-reboot=auto --transport containers-storage localhost/target-imgref-conflict } | complete + assert ($conflict.exit_code != 0) + assert ($conflict.stderr | str contains "Merge conflicts found in etc") + assert equal (status).status.staged $staged + rm /etc/target-imgref-conflict + let id = $booted.composefs.verity bootc delete-deployment $id assert equal (status).status.staged null @@ -121,10 +154,23 @@ def third_boot [] { assert (status).status.staged.downloadOnly bootc switch --download-only --transport containers-storage --target-imgref $next_origin $source if (tap is_composefs) { + # Resolve the same image successfully so failure proves lock enforcement. + podman start $registry + skopeo copy --preserve-digests --src-tls-verify=false --dest-tls-verify=false $"docker://($origin)" $"docker://($next_origin)" let applied = do { bootc update --apply } | complete assert ($applied.exit_code != 0) + assert ($applied.stderr | str contains "download-only") + let required = do { bootc switch --from-downloaded --soft-reboot=required } | complete + assert ($required.exit_code != 0) + assert (status).status.staged.downloadOnly + # Auto must unlock without rebooting the machine, even with --apply. + bootc switch --from-downloaded --soft-reboot=auto --apply + bootc update --soft-reboot=auto --apply + podman stop $registry + bootc switch --soft-reboot=auto --apply --transport containers-storage --target-imgref $next_origin $source + } else { + bootc switch --from-downloaded } - bootc switch --from-downloaded assert (not (status).status.staged.downloadOnly) tmt-reboot } @@ -135,8 +181,20 @@ def fourth_boot [] { assert equal (status).status.staged null assert equal (open $marker | str trim) "v2" bootc switch --transport containers-storage --target-imgref $next_origin $source - podman rm $registry - tap ok + podman start $registry + skopeo copy --preserve-digests --src-tls-verify=false --dest-tls-verify=false $"docker://($origin)" $"docker://($next_origin)" + bootc update + assert equal (status).status.staged null + + # A newer image at the recorded origin must be discovered by ordinary update. + cd (mktemp -d) + build_image "v3" "localhost/target-imgref-update:v3" + podman push --tls-verify=false localhost/target-imgref-update:v3 $next_origin + bootc update + let staged = (status).status.staged + assert equal $staged.image.image.image $next_origin + assert ($staged.image.imageDigest != $booted.image.imageDigest) + tmt-reboot } def main [] { @@ -145,6 +203,12 @@ def main [] { "1" => second_boot, "2" => third_boot, "3" => fourth_boot, + "4" => { + assert equal (open $marker | str trim) "v3" + assert equal (status).status.booted.image.image.image $next_origin + podman rm -f $registry + tap ok + }, $n => { error make { msg: $"Invalid reboot count ($n)" } }, } } From 3239055f0e0f10fe24f2341128fd430f1353d882 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Fri, 30 Oct 2026 15:01:15 +0530 Subject: [PATCH 09/10] docs: Explain redirected switch and activation behavior Explain how to obtain an image from a temporary source while retaining the normal update origin. Document repeat behavior, activation, and user-visible restrictions with local-storage and registry examples. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- docs/src/man/bootc-switch.8.md | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index c7f2b8ac8..39cbe398b 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -21,6 +21,29 @@ are tracking `:v1.0`, and as a rollout progresses, machines can be switched to ` It is also supported to provide explicit digests, via e.g. `bootc switch quay.io/exampleos/someuser@sha256:9cca0703342e24806a9f64e08c053dca7f2cd90f10529af8ea872afb0a0c77d4`. When you do this, `bootc upgrade` will always be a no-op. In this model, upgrades are then always triggered by further `switch` operations. +Use `--target-imgref` to fetch an image from local container storage or a temporary +registry while recording a different reference for subsequent updates. The +positional image and `--transport` select the source to fetch now; the target +reference is recorded without contacting it. A bare target name uses the registry +transport; explicit references such as `docker://quay.io/exampleos/myapp:v1.1` +are also accepted. + +Repeating the same source and target leaves the current deployment unchanged, +even if the temporary source is no longer available. Use `--from-downloaded` to +make a deployment staged with `--download-only` ready for the next boot. Use +`bootc upgrade` to check the recorded update source for newer content. + +On composefs, changing only the update origin of the identical OCI image preserves +the existing OS rollback entry. For a booted image, the change takes effect on +reboot after activation. Origin-only changes require a full reboot and cannot be +combined with UKI addons. `--soft-reboot=auto` leaves the change staged even with +`--apply`; `required` fails. Cancel any queued rollback or different staged +deployment before changing the booted image's origin. + +`--target-imgref` cannot be combined with `--from-downloaded`, `--mutate-in-place`, +or `--from-delta`. On composefs, it also cannot be combined with +`--enforce-container-sigpolicy`. + ## Applying Changes The `--apply` option will automatically restart the system if it has @@ -108,6 +131,18 @@ Switch with soft reboot if possible: bootc switch --apply --soft-reboot=auto quay.io/exampleos/myapp:v1.1 +Stage a locally loaded image while retaining a registry as the update source: + + bootc switch --download-only --transport containers-storage \ + --target-imgref quay.io/exampleos/myapp:v1.1 localhost/offline:v1.1 + bootc switch --from-downloaded + systemctl reboot + +Fetch from a technician's temporary registry and track the normal registry: + + bootc switch --target-imgref quay.io/exampleos/myapp:v1.1 \ + technician-laptop:5000/myapp:v1.1 + # SEE ALSO **bootc**(8), **bootc-upgrade**(8), **bootc-status**(8), **bootc-rollback**(8) From 7818ea5253a062f1a76631a4572da85c2d8159ba Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Sat, 31 Oct 2026 19:09:53 +0530 Subject: [PATCH 10/10] storage: Preserve unified storage across redirected switches A redirected deployment records its update origin while containers-storage keeps the delivery name. Detect the stored host image by its OCI config ID and retain that ID during cleanup, so later updates keep using unified storage without requiring another explicit opt-in. Use the existing deployment metadata for both backends without adding persistent state or changing image aliases. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- crates/lib/src/bootc_composefs/gc.rs | 4 +++ crates/lib/src/bootc_composefs/switch.rs | 17 +++++------- crates/lib/src/bootc_composefs/update.rs | 10 +++---- crates/lib/src/cli.rs | 12 +++++++- crates/lib/src/deploy.rs | 35 +++++++++++++++++++----- crates/lib/src/podstorage.rs | 12 ++++---- crates/lib/src/status.rs | 19 +++++++++++++ 7 files changed, 81 insertions(+), 28 deletions(-) diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 7468c05a2..039e50565 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -560,6 +560,10 @@ pub(crate) async fn composefs_gc( continue; } + if let Some(id) = deployment.container_image_id(storage)? { + live_container_images.insert(id); + } + // Keep the EROFS image as an additional root until all deployments // have manifest -> image refs. Once a deployment is pulled with the // new code, its EROFS image is reachable from the manifest and diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 716134b29..51f594219 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -114,19 +114,16 @@ pub(crate) async fn switch_composefs( let repo = &*booted_cfs.repo; - // Use unified storage if explicitly requested, or auto-detect: either the - // target image is already in bootc-owned containers-storage, OR the booted - // image is — which means the user has opted into unified storage and all - // subsequent operations (including switch to a new image) should use it. + // Preserve unified storage across switches, including when the current + // deployment's origin differs from its name in containers-storage. do_upgrade_opts.use_unified = if opts.unified_storage_exp { true } else { - let booted_imgref = host.spec.image.as_ref(); - let booted_unified = if let Some(booted) = booted_imgref { - crate::deploy::image_exists_in_unified_storage(storage, booted).await? - } else { - false - }; + let booted_unified = crate::deploy::deployment_uses_unified_storage( + storage, + host.status.staged.as_ref().or(host.status.booted.as_ref()), + ) + .await?; let target_unified = crate::deploy::image_exists_in_unified_storage(storage, target_imgref).await?; booted_unified || target_unified diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 263e3efee..a700e4c5d 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -699,11 +699,11 @@ pub(crate) async fn upgrade_composefs( // already in bootc-owned containers-storage, OR if the booted image is — // the latter means the user has opted into unified storage and all // subsequent operations should use it. - let current_unified = if let Some(current) = current_image { - crate::deploy::image_exists_in_unified_storage(storage, current).await? - } else { - false - }; + let current_unified = crate::deploy::deployment_uses_unified_storage( + storage, + host.status.staged.as_ref().or(host.status.booted.as_ref()), + ) + .await?; do_upgrade_opts.use_unified = current_unified || crate::deploy::image_exists_in_unified_storage(storage, booted_imgref).await?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 819ab7243..9079c19b5 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1552,7 +1552,12 @@ async fn upgrade( // Ensure the bootc storage directory is initialized; the --check path // needs this for update_mtime() and the non-check path needs it for // unified pull detection. - let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await?; + let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await? + || crate::deploy::deployment_uses_unified_storage( + storage, + host.status.staged.as_ref().or(host.status.booted.as_ref()), + ) + .await?; if let Some(delta) = delta.as_deref() { crate::delta::reject_unified_storage(delta, use_unified)?; } @@ -1876,6 +1881,11 @@ async fn switch_ostree( true } else { crate::deploy::image_exists_in_unified_storage(storage, &source).await? + || crate::deploy::deployment_uses_unified_storage( + storage, + host.status.staged.as_ref().or(host.status.booted.as_ref()), + ) + .await? }; if let Some(delta) = delta.as_deref() { crate::delta::reject_unified_storage(delta, use_unified)?; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index f69e75ab0..9a5c43946 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -445,27 +445,34 @@ pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> { let ostree = sysroot.get_ostree()?; let deployments = ostree.deployments(); let mut all_bound_images = Vec::new(); + let mut image_ids = Vec::new(); for deployment in deployments { let bound = crate::boundimage::query_bound_images_for_deployment(ostree, &deployment)?; all_bound_images.extend(bound.into_iter()); // Also include the host image itself // Note: Use just the image name (not the full transport:image format) because // podman's image names don't include the transport prefix. - if let Some(host_image) = crate::status::boot_entry_from_deployment(ostree, &deployment)? - .image - .map(|i| i.image) - { + let entry = crate::status::boot_entry_from_deployment(ostree, &deployment)?; + if let Some(id) = entry.container_image_id(sysroot)? { + image_ids.push(id); + } + if let Some(host_image) = entry.image.map(|i| i.image) { all_bound_images.push(crate::boundimage::BoundImage { image: host_image.image.clone(), auth_file: None, }); } } - // Convert to a hashset of just the image names - let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str())); + // Names retain bound images; IDs also retain host images with a redirected origin. + let roots = HashSet::from_iter( + all_bound_images + .iter() + .map(|img| img.image.as_str()) + .chain(image_ids.iter().map(String::as_str)), + ); let pruned = sysroot .get_ensure_imgstore()? - .prune_except_roots(&image_names) + .prune_except_roots(&roots) .await?; tracing::debug!("Pruned images: {}", pruned.len()); Ok(()) @@ -689,6 +696,20 @@ pub(crate) async fn image_exists_in_unified_storage( imgstore.exists(&image_ref_str).await } +/// Detect a deployment's stored image even when its pull source and origin differ. +pub(crate) async fn deployment_uses_unified_storage( + store: &Storage, + deployment: Option<&crate::spec::BootEntry>, +) -> Result { + let Some(deployment) = deployment else { + return Ok(false); + }; + let Some(id) = deployment.container_image_id(store)? else { + return Ok(false); + }; + store.get_ensure_imgstore()?.exists(&id).await +} + /// Unified approach: Use bootc's CStorage to pull the image, then prepare from containers-storage. /// This reuses the same infrastructure as LBIs. pub(crate) async fn prepare_for_pull_unified( diff --git a/crates/lib/src/podstorage.rs b/crates/lib/src/podstorage.rs index d55aa2d9b..cd199aade 100644 --- a/crates/lib/src/podstorage.rs +++ b/crates/lib/src/podstorage.rs @@ -415,16 +415,18 @@ impl CStorage { } #[context("Pruning")] + /// Retain images referenced by a name or a full containers-storage image ID. pub(crate) async fn prune_except_roots(&self, roots: &HashSet<&str>) -> Result> { let all_images = self.list_images().await?; tracing::debug!("Images total: {}", all_images.len(),); let mut garbage = Vec::new(); for image in all_images { - if image - .names - .iter() - .flatten() - .all(|name| !roots.contains(name.as_str())) + if !roots.contains(image.id.as_str()) + && image + .names + .iter() + .flatten() + .all(|name| !roots.contains(name.as_str())) { garbage.push(image.id); } diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index cae628a50..c622c4cb4 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -290,6 +290,25 @@ pub(crate) fn boot_entry_from_deployment( } impl BootEntry { + /// The containers-storage image ID is the OCI config digest, independent of + /// the name used to pull the image or the deployment's update origin. + pub(crate) fn container_image_id( + &self, + storage: &crate::store::Storage, + ) -> Result> { + if self.image.is_none() { + return Ok(None); + } + if let Some(composefs) = &self.composefs { + let (image, _) = + crate::bootc_composefs::status::get_imginfo(storage, &composefs.verity)?; + return Ok(Some(image.manifest.config().digest().digest().to_owned())); + } + Ok(self + .query_image(&storage.get_ostree()?.repo())? + .map(|image| image.manifest.config().digest().digest().to_owned())) + } + /// Given a boot entry, find its underlying ostree container image pub(crate) fn query_image( &self,