diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 3a7c2a5f4..bbdf9145a 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; +use cap_std_ext::cap_std; use clap::Parser; use fn_error_context::context; use ostree::gio; @@ -71,6 +72,12 @@ pub(crate) struct SwitchOpts { #[clap(long)] pub(crate) ostree_remote: Option, + /// Don't create a new deployment, but directly mutate the booted state. + /// This is hidden because it's not something we generally expect to be done, + /// but this can be used in e.g. Anaconda %post to fixup + #[clap(long, hide = true)] + pub(crate) mutate_in_place: bool, + /// Retain reference to currently booted image #[clap(long)] pub(crate) retain: bool, @@ -386,14 +393,6 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { /// Implementation of the `bootc switch` CLI command. #[context("Switching")] async fn switch(opts: SwitchOpts) -> Result<()> { - prepare_for_write().await?; - let cancellable = gio::Cancellable::NONE; - - let sysroot = &get_locked_sysroot().await?; - let repo = &sysroot.repo(); - let (booted_deployment, _deployments, host) = - crate::status::get_status_require_booted(sysroot)?; - let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; let imgref = ostree_container::ImageReference { transport, @@ -406,6 +405,29 @@ async fn switch(opts: SwitchOpts) -> Result<()> { let target = ostree_container::OstreeImageReference { sigverify, imgref }; let target = ImageReference::from(target); + // If we're doing an in-place mutation, we shortcut most of the rest of the work here + if opts.mutate_in_place { + let deployid = { + // Clone to pass into helper thread + let target = target.clone(); + let root = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + tokio::task::spawn_blocking(move || { + crate::deploy::switch_origin_inplace(&root, &target) + }) + .await?? + }; + println!("Updated {deployid} to pull from {target}"); + return Ok(()); + } + + prepare_for_write().await?; + let cancellable = gio::Cancellable::NONE; + + let sysroot = &get_locked_sysroot().await?; + let repo = &sysroot.repo(); + let (booted_deployment, _deployments, host) = + crate::status::get_status_require_booted(sysroot)?; + let new_spec = { let mut new_spec = host.spec.clone(); new_spec.image = Some(target.clone()); diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs index fbcc67279..482359187 100644 --- a/lib/src/deploy.rs +++ b/lib/src/deploy.rs @@ -2,8 +2,12 @@ //! //! Create a merged filesystem tree with the image and mounted configmaps. +use anyhow::Ok; use anyhow::{Context, Result}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree::{gio, glib}; use ostree_container::OstreeImageReference; @@ -12,6 +16,7 @@ use ostree_ext::container::store::PrepareResult; use ostree_ext::ostree; use ostree_ext::ostree::Deployment; use ostree_ext::sysroot::SysrootLock; +use rustix::fs::MetadataExt; use crate::spec::HostSpec; use crate::spec::ImageReference; @@ -202,6 +207,18 @@ async fn deploy( Ok(()) } +#[context("Generating origin")] +fn origin_from_imageref(imgref: &ImageReference) -> Result { + let origin = glib::KeyFile::new(); + let imgref = OstreeImageReference::from(imgref.clone()); + origin.set_string( + "origin", + ostree_container::deploy::ORIGIN_CONTAINER, + imgref.to_string().as_str(), + ); + Ok(origin) +} + /// Stage (queue deployment of) a fetched container image. #[context("Staging")] pub(crate) async fn stage( @@ -211,13 +228,7 @@ pub(crate) async fn stage( spec: &RequiredHostSpec<'_>, ) -> Result<()> { let merge_deployment = sysroot.merge_deployment(Some(stateroot)); - let origin = glib::KeyFile::new(); - let imgref = OstreeImageReference::from(spec.image.clone()); - origin.set_string( - "origin", - ostree_container::deploy::ORIGIN_CONTAINER, - imgref.to_string().as_str(), - ); + let origin = origin_from_imageref(spec.image)?; crate::deploy::deploy( sysroot, merge_deployment.as_ref(), @@ -227,7 +238,7 @@ pub(crate) async fn stage( ) .await?; crate::deploy::cleanup(sysroot).await?; - println!("Queued for next boot: {imgref}"); + println!("Queued for next boot: {}", spec.image); if let Some(version) = image.version.as_deref() { println!(" Version: {version}"); } @@ -235,3 +246,107 @@ pub(crate) async fn stage( Ok(()) } + +fn find_newest_deployment_name(deploysdir: &Dir) -> Result { + let mut dirs = Vec::new(); + for ent in deploysdir.entries()? { + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let name = ent.file_name(); + let name = if let Some(name) = name.to_str() { + name + } else { + continue; + }; + dirs.push((name.to_owned(), ent.metadata()?.mtime())); + } + dirs.sort_unstable_by(|a, b| a.1.cmp(&b.1)); + if let Some((name, _ts)) = dirs.pop() { + Ok(name) + } else { + anyhow::bail!("No deployment directory found") + } +} + +// Implementation of `bootc switch --in-place` +pub(crate) fn switch_origin_inplace(root: &Dir, imgref: &ImageReference) -> Result { + // First, just create the new origin file + let origin = origin_from_imageref(imgref)?; + let serialized_origin = origin.to_data(); + + // Now, we can't rely on being officially booted (e.g. with the `ostree=` karg) + // in a scenario like running in the anaconda %post. + // Eventually, we should support a setup here where ostree-prepare-root + // can officially be run to "enter" an ostree root in a supportable way. + // Anyways for now, the brutal hack is to just scrape through the deployments + // and find the newest one, which we will mutate. If there's more than one, + // ultimately the calling tooling should be fixed to set things up correctly. + + let mut ostree_deploys = root.open_dir("sysroot/ostree/deploy")?.entries()?; + let deploydir = loop { + if let Some(ent) = ostree_deploys.next() { + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + tracing::debug!("Checking {:?}", ent.file_name()); + let child_dir = ent + .open_dir() + .with_context(|| format!("Opening dir {:?}", ent.file_name()))?; + if let Some(d) = child_dir.open_dir_optional("deploy")? { + break d; + } + } else { + anyhow::bail!("Failed to find a deployment"); + } + }; + let newest_deployment = find_newest_deployment_name(&deploydir)?; + let origin_path = format!("{newest_deployment}.origin"); + if !deploydir.try_exists(&origin_path)? { + tracing::warn!("No extant origin for {newest_deployment}"); + } + deploydir + .atomic_write(&origin_path, serialized_origin.as_bytes()) + .context("Writing origin")?; + return Ok(newest_deployment); +} + +#[test] +fn test_switch_inplace() -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + let mut builder = cap_std::fs::DirBuilder::new(); + let builder = builder.recursive(true).mode(0o755); + let deploydir = "sysroot/ostree/deploy/default/deploy"; + let target_deployment = "af36eb0086bb55ac601600478c6168f834288013d60f8870b7851f44bf86c3c5.0"; + td.ensure_dir_with( + format!("sysroot/ostree/deploy/default/deploy/{target_deployment}"), + builder, + )?; + let deploydir = &td.open_dir(deploydir)?; + let orig_imgref = ImageReference { + image: "quay.io/exampleos/original:sometag".into(), + transport: "registry".into(), + signature: None, + }; + { + let origin = origin_from_imageref(&orig_imgref)?; + deploydir.atomic_write( + format!("{target_deployment}.origin"), + origin.to_data().as_bytes(), + )?; + } + + let target_imgref = ImageReference { + image: "quay.io/someother/otherimage:latest".into(), + transport: "registry".into(), + signature: None, + }; + + let replaced = switch_origin_inplace(&td, &target_imgref).unwrap(); + assert_eq!(replaced, target_deployment); + Ok(()) +} diff --git a/lib/src/spec.rs b/lib/src/spec.rs index b018671f5..4d9a08aee 100644 --- a/lib/src/spec.rs +++ b/lib/src/spec.rs @@ -1,5 +1,8 @@ //! The definition for host system state. +use std::fmt::Display; + +use ostree_ext::container::OstreeImageReference; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -149,8 +152,17 @@ impl Default for Host { } } +impl Display for ImageReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ostree_imgref = OstreeImageReference::from(self.clone()); + ostree_imgref.fmt(f) + } +} + #[cfg(test)] mod tests { + use std::str::FromStr; + use super::*; #[test] @@ -183,4 +195,13 @@ mod tests { Some(ImageSignature::OstreeRemote("fedora".into())) ); } + + #[test] + fn test_display_imgref() { + let src = "ostree-unverified-registry:quay.io/example/foo:sometag"; + let s = OstreeImageReference::from_str(src).unwrap(); + let s = ImageReference::from(s); + let displayed = format!("{s}"); + assert_eq!(displayed.as_str(), src); + } }