diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index e01a587161..4451fc5814 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/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 955c32f20d..6413c17fcf 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 5e686bc3b6..320eca70e8 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/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 7468c05a2f..039e505659 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/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index e6d6f87875..269f6660f6 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 0000000000..3817f58bed --- /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 37d7ed4ef7..14a36640d1 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 2bfd3317c1..260b514f63 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/state.rs b/crates/lib/src/bootc_composefs/state.rs index 57055a3de6..ea1130a0bc 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 @@ -255,6 +280,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, @@ -285,13 +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").item( - ORIGIN_CONTAINER, - // TODO (Johan-Liebert1): The image won't always be unverified - format!("ostree-unverified-image:{imgref}"), - ); + let mut config = with_image_references( + tini::Ini::new(), + source_imgref.filter(|source| *source != target_imgref), + target_imgref, + )?; config = config .section(ORIGIN_KEY_BOOT) @@ -307,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/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe8..66725d5417 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 ee5f622d3f..51f5942196 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::{ @@ -10,7 +11,10 @@ use crate::{ validate_update, }, }, - cli::{SwitchOpts, imgref_for_switch}, + cli::{ + SwitchOpts, handle_unchanged_switch, imgref_for_switch, switch_images_unchanged, + target_imgref_for_switch, + }, progress_jsonl::ProgressWriter, store::{BootedComposefs, Storage}, }; @@ -45,35 +49,63 @@ pub(crate) async fn switch_composefs( return apply_upgrade_from_downloaded(storage, booted_cfs, &host, &do_upgrade_opts).await; } - let target = imgref_for_switch(&opts)?; + 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 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 - }; - - if new_spec == host.spec { - println!("Image specification is unchanged."); - if opts.apply && host.status.staged.is_some() { - crate::reboot::reboot()?; + 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 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 Ok(()); + 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, @@ -82,41 +114,52 @@ 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?; + crate::deploy::image_exists_in_unified_storage(storage, target_imgref).await?; booted_unified || target_unified }; // 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, @@ -138,7 +181,8 @@ pub(crate) async fn switch_composefs( storage, booted_cfs, &host, - &target_imgref, + target_imgref, + final_target_imgref.as_ref(), &do_upgrade_opts, &manifest, ) @@ -151,7 +195,8 @@ pub(crate) async fn switch_composefs( storage, booted_cfs, &host, - &target_imgref, + target_imgref, + final_target_imgref.as_ref(), &do_upgrade_opts, &manifest, ) @@ -159,3 +204,75 @@ 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) + .or_else(|| host.status.booted.as_ref().filter(same_image)); + let Some(entry) = entry else { return Ok(false) }; + 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; + let reboot = super::origin::should_reboot(opts.soft_reboot, opts.apply, locked)?; + let deployment = &entry.require_composefs()?.verity; + super::service::start_finalize_stated_svc()?; + 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 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 05c93fcc11..c049c42a67 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 7fc373d9b0..a700e4c5d6 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(), @@ -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<()> { @@ -326,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)?; @@ -465,7 +474,8 @@ pub(crate) async fn do_upgrade( write_composefs_state( &Utf8PathBuf::from("/sysroot"), &deploy_id, - imgref, + target_imgref.unwrap_or(imgref), + Some(imgref), false, boot_type, boot_digest, @@ -485,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 @@ -545,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 @@ -559,9 +588,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 = @@ -572,13 +627,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")] @@ -643,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?; @@ -684,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(); } @@ -723,6 +792,7 @@ pub(crate) async fn upgrade_composefs( composefs, &host, booted_imgref, + None, &do_upgrade_opts, &manifest, ) @@ -755,6 +825,7 @@ pub(crate) async fn upgrade_composefs( composefs, &host, booted_imgref, + None, &do_upgrade_opts, &manifest, ) @@ -775,6 +846,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 0059f45f82..9079c19b51 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,17 @@ 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, + + /// 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. @@ -1541,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)?; } @@ -1661,6 +1677,7 @@ async fn upgrade( &spec, prog.clone(), opts.download_opts.download_only, + None, ) .await?; changed = true; @@ -1693,6 +1710,63 @@ 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 sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy); + let target = ostree_container::OstreeImageReference { + sigverify, + imgref: target_imgref.clone(), + }; + + Ok(Some(ImageReference::from(target).canonicalize()?)) +} + +/// The origin alone cannot distinguish two deliveries from different sources. +/// Older deployments without provenance used their origin as the pull source. +pub(crate) fn switch_images_unchanged( + source: &ImageReference, + target: Option<&ImageReference>, + 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())?; @@ -1722,7 +1796,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( @@ -1737,8 +1811,13 @@ async fn switch_ostree( .await; } - let target = imgref_for_switch(&opts)?; - let prog: ProgressWriter = opts.progress.try_into()?; + 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; let repo = &booted_ostree.repo(); @@ -1749,12 +1828,27 @@ async fn switch_ostree( new_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 @@ -1771,6 +1865,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 @@ -1784,7 +1880,12 @@ 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? + || 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)?; @@ -1793,7 +1894,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(), @@ -1803,8 +1904,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, @@ -1814,8 +1915,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), @@ -1836,6 +1937,7 @@ async fn switch_ostree( let stateroot = booted_ostree.stateroot(); let from = MergeState::from_stateroot(storage, &stateroot)?; + crate::deploy::stage( storage, from, @@ -1843,6 +1945,7 @@ async fn switch_ostree( &new_spec, prog.clone(), opts.download_opts.download_only, + Some(&source), ) .await?; @@ -1888,6 +1991,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) => { @@ -1989,7 +2093,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()?; @@ -2979,6 +3092,165 @@ async fn run_from_opt(opt: Opt) -> 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_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 1f19afc622..9a5c439462 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. @@ -441,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(()) @@ -685,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( @@ -746,12 +771,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 +785,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))) } @@ -1183,6 +1193,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 { @@ -1255,6 +1276,7 @@ pub(crate) async fn stage( spec: &RequiredHostSpec<'_>, prog: ProgressWriter, lock_finalization: bool, + source_imgref: Option<&ImageReference>, ) -> Result<()> { // Log the staging operation to systemd journal with comprehensive upgrade information const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c"; @@ -1333,7 +1355,9 @@ pub(crate) async fn stage( .collect(), }) .await; - let origin = origin_from_imageref(spec.image)?; + + let origin = origin_for_switch(spec.image, source_imgref)?; + crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?; subtask.completed = true; @@ -1640,8 +1664,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/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index dba8485b25..e064857de0 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/crates/lib/src/install.rs b/crates/lib/src/install.rs index 36ae983937..efcccc1995 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)? { diff --git a/crates/lib/src/podstorage.rs b/crates/lib/src/podstorage.rs index d55aa2d9b2..cd199aadee 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/spec.rs b/crates/lib/src/spec.rs index ab3428d312..8786fbe252 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/crates/lib/src/status.rs b/crates/lib/src/status.rs index cae628a50d..c622c4cb4a 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, diff --git a/crates/ostree-ext/src/container/store.rs b/crates/ostree-ext/src/container/store.rs index 9316d85470..1ee6649c22 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 8f973d3c44..bd14f13049 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(()) } diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index 5e6b3d3b3b..39cbe398bd 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 @@ -88,6 +111,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 @@ -104,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) diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index bc150a0b70..510203751c 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 0000000000..3cb74811c4 --- /dev/null +++ b/tmt/tests/booted/test-switch-target-imgref.nu @@ -0,0 +1,214 @@ +# 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 + + # 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. + 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 + 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" + 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 + 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 + + # 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 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 + 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) { + # 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 + } + 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 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 [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "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)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 7b7c804992..5b652b0ce9 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