diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 1a295d071..801a019bd 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1277,7 +1277,7 @@ pub(crate) async fn setup_composefs_boot( &root_setup.physical_root_path, &id, &crate::spec::ImageReference::from(state.target_imgref.clone()), - false, + None, boot_type, boot_digest, &get_container_manifest_and_config(&get_imgref( diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index b8abd4b4c..f86392421 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -50,7 +50,8 @@ pub async fn export_repo_to_image( let imginfo = get_imginfo(storage, &depl_verity, None).await?; - let config_digest = imginfo.manifest.config().digest().digest(); + // We want the digest in the form of "sha256:abc123" + let config_digest = format!("{}", imginfo.manifest.config().digest()); let var_tmp = Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?; @@ -60,7 +61,7 @@ pub async fn export_repo_to_image( // Use composefs_oci::open_config to get the config and layer map let (config, layer_map) = - open_config(&*booted_cfs.repo, config_digest, None).context("Opening config")?; + open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; // We can't guarantee that we'll get the same tar stream as the container image // So we create new config and manifest diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 027ffb5ee..68c302033 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -52,6 +52,11 @@ pub(crate) async fn composefs_backend_finalize( return Ok(()); }; + if staged_depl.download_only { + tracing::debug!("Staged deployment is marked download only. Won't finalize"); + return Ok(()); + } + 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/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 3e4509da1..9ec3d6157 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -2,6 +2,7 @@ use crate::{ bootc_composefs::{ service::start_finalize_stated_svc, status::composefs_deployment_status_from, }, + cli::SoftRebootMode, composefs_consts::COMPOSEFS_CMDLINE, store::{BootedComposefs, Storage}, }; @@ -10,25 +11,66 @@ use bootc_initramfs_setup::setup_root; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::{PID1, bind_mount_from_pidns}; use camino::Utf8Path; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree_ext::systemd_has_soft_reboot; +use rustix::mount::{UnmountFlags, unmount}; use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, process::Command}; const NEXTROOT: &str = "/run/nextroot"; +#[context("Resetting soft reboot state")] +pub(crate) fn reset_soft_reboot() -> Result<()> { + let run = Utf8Path::new("/run"); + bind_mount_from_pidns(PID1, &run, &run, true).context("Bind mounting /run")?; + + let run_dir = Dir::open_ambient_dir("/run", ambient_authority()).context("Opening run")?; + + let nextroot = run_dir + .open_dir_optional("nextroot") + .context("Opening nextroot")?; + + let Some(nextroot) = nextroot else { + tracing::debug!("Nextroot is not a directory"); + println!("No deployment staged for soft rebooting"); + return Ok(()); + }; + + let nextroot_mounted = nextroot + .is_mountpoint(".")? + .ok_or_else(|| anyhow::anyhow!("Failed to get mount info"))?; + + if !nextroot_mounted { + tracing::debug!("Nextroot is not a mountpoint"); + println!("No deployment staged for soft rebooting"); + return Ok(()); + } + + unmount(NEXTROOT, UnmountFlags::DETACH).context("Unmounting nextroot")?; + + println!("Soft reboot state cleared successfully"); + + Ok(()) +} + /// Checks if the provided deployment is soft reboot capable, and soft reboots the system if /// argument `reboot` is true #[context("Soft rebooting")] pub(crate) async fn prepare_soft_reboot_composefs( storage: &Storage, booted_cfs: &BootedComposefs, - deployment_id: &String, + deployment_id: Option<&String>, + soft_reboot_mode: SoftRebootMode, reboot: bool, ) -> Result<()> { if !systemd_has_soft_reboot() { anyhow::bail!("System does not support soft reboots") } + let deployment_id = deployment_id.ok_or_else(|| anyhow::anyhow!("Expected deployment id"))?; + if *deployment_id == *booted_cfs.cmdline.digest { anyhow::bail!("Cannot soft-reboot to currently booted deployment"); } @@ -44,7 +86,13 @@ pub(crate) async fn prepare_soft_reboot_composefs( .ok_or_else(|| anyhow::anyhow!("Deployment '{deployment_id}' not found"))?; if !requred_deployment.soft_reboot_capable { - anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state"); + match soft_reboot_mode { + SoftRebootMode::Required => { + anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state") + } + + SoftRebootMode::Auto => 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 0dc20f8be..517281be0 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -9,6 +9,7 @@ use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::tempmount::TempMount; use bootc_utils::CommandRunExt; use camino::Utf8PathBuf; +use canon_json::CanonJsonSerialize; use cap_std_ext::cap_std::ambient_authority; use cap_std_ext::cap_std::fs::{Dir, Permissions, PermissionsExt}; use cap_std_ext::dirext::CapStdExtDirExt; @@ -23,7 +24,9 @@ use rustix::{ use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::repo::get_imgref; -use crate::bootc_composefs::status::{ImgConfigManifest, get_sorted_type1_boot_entries}; +use crate::bootc_composefs::status::{ + ImgConfigManifest, StagedDeployment, get_sorted_type1_boot_entries, +}; use crate::parsers::bls_config::BLSConfigType; use crate::store::{BootedComposefs, Storage}; use crate::{ @@ -227,7 +230,7 @@ pub(crate) async fn write_composefs_state( root_path: &Utf8PathBuf, deployment_id: &Sha512HashValue, target_imgref: &ImageReference, - staged: bool, + staged: Option, boot_type: BootType, boot_digest: String, container_details: &ImgConfigManifest, @@ -248,7 +251,12 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to create symlink for /var")?; - initialize_state(&root_path, &deployment_id.to_hex(), &state_path, !staged)?; + initialize_state( + &root_path, + &deployment_id.to_hex(), + &state_path, + staged.is_none(), + )?; let ImageReference { image: image_name, @@ -291,7 +299,7 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to write to .origin file")?; - if staged { + if let Some(staged) = staged { std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; @@ -302,7 +310,9 @@ pub(crate) async fn write_composefs_state( staged_depl_dir .atomic_write( COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - deployment_id.to_hex().as_bytes(), + staged + .to_canon_json_vec() + .context("Failed to serialize staged deployment JSON")?, ) .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 386e2470a..c5da0d20c 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -79,6 +79,12 @@ impl std::fmt::Display for ComposefsCmdline { } } +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct StagedDeployment { + pub(crate) depl_id: String, + pub(crate) finalization_locked: bool, +} + /// Detect if we have composefs= in /proc/cmdline pub(crate) fn composefs_booted() -> Result> { static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); @@ -554,7 +560,7 @@ pub(crate) async fn composefs_deployment_status_from( let mut host = Host::new(host_spec); - let staged_deployment_id = match std::fs::File::open(format!( + let staged_deployment = match std::fs::File::open(format!( "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" )) { Ok(mut f) => { @@ -590,7 +596,7 @@ pub(crate) async fn composefs_deployment_status_from( let ini = tini::Ini::from_string(&config) .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; - let boot_entry = + let mut boot_entry = boot_entry_from_composefs_deployment(storage, ini, depl_file_name.to_string()).await?; // SAFETY: boot_entry.composefs will always be present @@ -614,8 +620,11 @@ pub(crate) async fn composefs_deployment_status_from( continue; } - if let Some(staged_deployment_id) = &staged_deployment_id { - if depl_file_name == staged_deployment_id.trim() { + if let Some(staged_deployment) = &staged_deployment { + let staged_depl = serde_json::from_str::(&staged_deployment)?; + + if depl_file_name == staged_depl.depl_id { + boot_entry.download_only = staged_depl.finalization_locked; host.status.staged = Some(boot_entry); continue; } diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 4f12b4790..944e166c3 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -45,6 +45,7 @@ pub(crate) async fn switch_composefs( let do_upgrade_opts = DoUpgradeOpts { soft_reboot: opts.soft_reboot, apply: opts.apply, + download_only: false, }; if let Some(cfg_verity) = image { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index f6252a65f..8b74f4c03 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,10 +1,14 @@ +use std::io::Write; + use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use cap_std_ext::cap_std::fs::Dir; +use canon_json::CanonJsonSerialize; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; use composefs_oci::image::create_filesystem; use fn_error_context::context; +use ocidir::cap_std::ambient_authority; use ostree_ext::container::ManifestDiff; use crate::{ @@ -15,12 +19,15 @@ use crate::{ soft_reboot::prepare_soft_reboot_composefs, state::write_composefs_state, status::{ - ImgConfigManifest, get_bootloader, get_composefs_status, + ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status, get_container_manifest_and_config, get_imginfo, }, }, cli::{SoftRebootMode, UpgradeOpts}, - composefs_consts::{STATE_DIR_RELATIVE, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED}, + composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, + TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED, + }, spec::{Bootloader, Host, ImageReference}, store::{BootedComposefs, ComposefsRepository, Storage}, }; @@ -206,6 +213,31 @@ pub(crate) fn validate_update( pub(crate) struct DoUpgradeOpts { pub(crate) apply: bool, pub(crate) soft_reboot: Option, + pub(crate) download_only: bool, +} + +async fn apply_upgrade( + storage: &Storage, + booted_cfs: &BootedComposefs, + depl_id: &String, + opts: &DoUpgradeOpts, +) -> Result<()> { + if let Some(soft_reboot_mode) = opts.soft_reboot { + return prepare_soft_reboot_composefs( + storage, + booted_cfs, + Some(depl_id), + soft_reboot_mode, + opts.apply, + ) + .await; + }; + + if opts.apply { + return crate::reboot::reboot(); + } + + Ok(()) } /// Performs the Update or Switch operation @@ -255,22 +287,17 @@ pub(crate) async fn do_upgrade( &Utf8PathBuf::from("/sysroot"), &id, imgref, - true, + Some(StagedDeployment { + depl_id: id.to_hex(), + finalization_locked: opts.download_only, + }), boot_type, boot_digest, img_manifest_config, ) .await?; - if opts.apply { - return crate::reboot::reboot(); - } - - if opts.soft_reboot.is_some() { - prepare_soft_reboot_composefs(storage, booted_cfs, &id.to_hex(), true).await?; - } - - Ok(()) + apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await } #[context("Upgrading composefs")] @@ -279,18 +306,60 @@ pub(crate) async fn upgrade_composefs( storage: &Storage, composefs: &BootedComposefs, ) -> Result<()> { - // Download-only mode is not yet supported for composefs backend - if opts.download_only { - anyhow::bail!("--download-only is not yet supported for composefs backend"); - } - if opts.from_downloaded { - anyhow::bail!("--from-downloaded is not yet supported for composefs backend"); - } - let host = get_composefs_status(storage, composefs) .await .context("Getting composefs deployment status")?; + let do_upgrade_opts = DoUpgradeOpts { + soft_reboot: opts.soft_reboot, + apply: opts.apply, + download_only: opts.download_only, + }; + + if opts.from_downloaded { + let staged = host + .status + .staged + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; + + // Staged deployment exists, but it will be finalized + if !staged.download_only { + println!("Staged deployment is present and not in download only mode."); + println!("Use `bootc update --apply` to apply the update."); + return Ok(()); + } + + start_finalize_stated_svc()?; + + // Make the staged deployment not download_only + let new_staged = StagedDeployment { + depl_id: staged.require_composefs()?.verity.clone(), + finalization_locked: false, + }; + + let staged_depl_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient state directory")?; + + staged_depl_dir + .atomic_replace_with( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + |f| -> std::io::Result<()> { + f.write_all(new_staged.to_canon_json_string()?.as_bytes()) + }, + ) + .context("Writing staged file")?; + + return apply_upgrade( + storage, + composefs, + &staged.require_composefs()?.verity, + &do_upgrade_opts, + ) + .await; + } + let mut booted_imgref = host .spec .image @@ -306,11 +375,6 @@ pub(crate) async fn upgrade_composefs( // Or if we have another staged deployment with a different image let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref()); - let do_upgrade_opts = DoUpgradeOpts { - soft_reboot: opts.soft_reboot, - apply: opts.apply, - }; - if let Some(staged_image) = staged_image { // We have a staged image and it has the same digest as the currently booted image's latest // digest diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b4887f619..5d26ef4a9 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -34,7 +34,7 @@ use schemars::schema_for; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::delete::delete_composefs_deployment; -use crate::bootc_composefs::soft_reboot::prepare_soft_reboot_composefs; +use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, @@ -618,9 +618,12 @@ pub(crate) enum InternalsOpts { /// Dump CLI structure as JSON for documentation generation DumpCliJson, PrepSoftReboot { - deployment: String, - #[clap(long)] + #[clap(required_unless_present = "reset")] + deployment: Option, + #[clap(long, conflicts_with = "reset")] reboot: bool, + #[clap(long, conflicts_with = "reboot")] + reset: bool, }, } @@ -1834,7 +1837,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> { Ok(()) } - InternalsOpts::PrepSoftReboot { deployment, reboot } => { + InternalsOpts::PrepSoftReboot { + deployment, + reboot, + reset, + } => { let storage = &get_storage().await?; match storage.kind()? { @@ -1842,9 +1849,20 @@ async fn run_from_opt(opt: Opt) -> Result<()> { // TODO: Call ostree implementation? anyhow::bail!("soft-reboot only implemented for composefs") } + BootedStorageKind::Composefs(booted_cfs) => { - prepare_soft_reboot_composefs(&storage, &booted_cfs, &deployment, reboot) - .await + if reset { + return reset_soft_reboot(); + } + + prepare_soft_reboot_composefs( + &storage, + &booted_cfs, + deployment.as_ref(), + SoftRebootMode::Required, + reboot, + ) + .await } } }