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 03b62e2ea..0f8ffab08 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -57,6 +57,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/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index d3dda0f5f..0660cdcd7 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod finalize; pub(crate) mod gc; pub(crate) mod repo; pub(crate) mod rollback; +pub(crate) mod selinux; pub(crate) mod service; pub(crate) mod soft_reboot; pub(crate) mod state; diff --git a/crates/lib/src/bootc_composefs/selinux.rs b/crates/lib/src/bootc_composefs/selinux.rs new file mode 100644 index 000000000..700275264 --- /dev/null +++ b/crates/lib/src/bootc_composefs/selinux.rs @@ -0,0 +1,315 @@ +use anyhow::{Context, Result}; +use bootc_initramfs_setup::mount_composefs_image; +use bootc_mount::tempmount::TempMount; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; + +use crate::bootc_composefs::status::ComposefsCmdline; +use crate::lsm::selinux_enabled; +use crate::store::Storage; + +const SELINUX_CONFIG_PATH: &str = "etc/selinux/config"; +const SELINUX_TYPE: &str = "SELINUXTYPE="; +const POLICY_FILE_PREFIX: &str = "policy."; + +/// Find the highest versioned policy file in the given directory +fn find_latest_policy_file(policy_dir: &Dir) -> Result { + let mut highest_policy_version = -1; + let mut latest_policy_name = None; + + for entry in policy_dir + .entries_utf8() + .context("Getting policy dir entries")? + { + let entry = entry?; + + if !entry.file_type()?.is_file() { + // We don't want symlinks, another directory etc + continue; + } + + let filename = entry.file_name()?; + + match filename.strip_prefix(POLICY_FILE_PREFIX) { + Some(version) => { + let v_int = version + .parse::() + .with_context(|| anyhow::anyhow!("Parsing {version} as int"))?; + + if v_int < highest_policy_version { + continue; + } + + highest_policy_version = v_int; + latest_policy_name = Some(filename.to_string()); + } + + None => continue, + }; + } + + latest_policy_name.ok_or_else(|| anyhow::anyhow!("Failed to get latest SELinux policy")) +} + +/// Compute SHA256 hash of a policy file +fn compute_policy_file_hash(deployment_root: &Dir, full_path: &str) -> Result { + let mut file = deployment_root + .open(full_path) + .context("Opening policy file")?; + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + std::io::copy(&mut file, &mut hasher)?; + + let hash = hex::encode(hasher.finish().context("Computing hash")?); + Ok(hash) +} + +#[context("Getting SELinux policy for deployment {depl_id}")] +fn get_selinux_policy_for_deployment( + storage: &Storage, + booted_cmdline: &ComposefsCmdline, + depl_id: &str, +) -> Result> { + let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; + + // Booted deployment. We want to get the policy from "/etc" as it might have been modified + let (deployment_root, _mount_guard) = if *booted_cmdline.digest == *depl_id { + (Dir::open_ambient_dir("/", ambient_authority())?, None) + } else { + let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, false)?; + let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; + + (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt)) + }; + + if !deployment_root.exists(SELINUX_CONFIG_PATH) { + return Ok(None); + } + + let selinux_config = deployment_root + .read_to_string(SELINUX_CONFIG_PATH) + .context("Reading selinux config")?; + + let type_ = selinux_config + .lines() + .find(|l| l.starts_with(SELINUX_TYPE)) + .ok_or_else(|| anyhow::anyhow!("Falied to find SELINUXTYPE"))? + .split("=") + .nth(1) + .ok_or_else(|| anyhow::anyhow!("Failed to parse SELINUXTYPE"))? + .trim(); + + let policy_dir_path = format!("etc/selinux/{type_}/policy"); + + let policy_dir = deployment_root + .open_dir(&policy_dir_path) + .context("Opening selinux policy dir")?; + + let policy_name = find_latest_policy_file(&policy_dir)?; + + let full_path = format!("{policy_dir_path}/{policy_name}"); + + let hash = compute_policy_file_hash(&deployment_root, &full_path)?; + + Ok(Some(hash)) +} + +#[context("Checking SELinux policy compatibility")] +pub(crate) fn are_selinux_policies_compatible( + storage: &Storage, + booted_cmdline: &ComposefsCmdline, + depl_id: &str, +) -> Result { + if !selinux_enabled()? { + return Ok(true); + } + + let booted_policy_hash = + get_selinux_policy_for_deployment(storage, booted_cmdline, &booted_cmdline.digest)?; + + let depl_policy_hash = get_selinux_policy_for_deployment(storage, booted_cmdline, depl_id)?; + + let sl_policy_match = match (booted_policy_hash, depl_policy_hash) { + // both have policies, compare them + (Some(booted_csum), Some(target_csum)) => booted_csum == target_csum, + // one depl has policy while the other doesn't + (Some(_), None) | (None, Some(_)) => false, + // no policy in either + (None, None) => true, + }; + + if !sl_policy_match { + tracing::debug!("Soft rebooting not allowed due to differing SELinux policies"); + } + + Ok(sl_policy_match) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std::ambient_authority; + use cap_std_ext::dirext::CapStdExtDirExt; + + #[test] + fn test_find_latest_policy_file() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + // Create policy files with different versions + tempdir.atomic_write("policy.30", "policy content 30")?; + tempdir.atomic_write("policy.31", "policy content 31")?; + tempdir.atomic_write("policy.29", "policy content 29")?; + tempdir.atomic_write("not_policy.32", "not a policy file")?; + tempdir.atomic_write("other_policy.txt", "invalid policy file")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.31"); + + Ok(()) + } + + #[test] + fn test_find_latest_policy_file_with_single_file() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("policy.25", "single policy file")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.25"); + + Ok(()) + } + + #[test] + fn test_find_latest_policy_file_no_policy_files() { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap(); + + tempdir + .atomic_write("not_policy.txt", "not a policy file") + .unwrap(); + tempdir.atomic_write("other.txt", "invalid format").unwrap(); + + let result = find_latest_policy_file(&tempdir); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to get latest SELinux policy") + ); + } + + #[test] + fn test_find_latest_policy_file_invalid_version() { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap(); + + tempdir + .atomic_write("policy.abc", "invalid version") + .unwrap(); + + let result = find_latest_policy_file(&tempdir); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Parsing abc as int") + ); + } + + #[test] + fn test_find_latest_policy_file_negative_version() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("policy.5", "positive version")?; + tempdir.atomic_write("policy.-1", "negative version")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.5"); + + Ok(()) + } + + #[test] + fn test_find_latest_policy_file_skips_directories() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.create_dir("policy.99")?; // This should be skipped + tempdir.atomic_write("policy.5", "actual policy file")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.5"); + + Ok(()) + } + + #[test] + fn test_compute_policy_file_hash() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + let test_content = "test policy content for hashing"; + tempdir.atomic_write("test_policy.30", test_content)?; + + let hash = compute_policy_file_hash(&tempdir, "test_policy.30")?; + + // Verify the hash is a valid SHA256 hash (64 hex characters) + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + + // Verify consistent hashing + let hash2 = compute_policy_file_hash(&tempdir, "test_policy.30")?; + assert_eq!(hash, hash2); + + Ok(()) + } + + #[test] + fn test_compute_policy_file_hash_different_content() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("policy1.30", "content 1")?; + tempdir.atomic_write("policy2.30", "content 2")?; + + let hash1 = compute_policy_file_hash(&tempdir, "policy1.30")?; + let hash2 = compute_policy_file_hash(&tempdir, "policy2.30")?; + + assert_ne!(hash1, hash2); + + Ok(()) + } + + #[test] + fn test_compute_policy_file_hash_nonexistent_file() { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap(); + + let result = compute_policy_file_hash(&tempdir, "nonexistent.30"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Opening policy file") + ); + } + + #[test] + fn test_compute_policy_file_hash_empty_file() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("empty_policy.30", "")?; + + let hash = compute_policy_file_hash(&tempdir, "empty_policy.30")?; + + // Should produce a valid hash even for empty file + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + + // SHA256 of empty string + assert_eq!( + hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 3e4509da1..0a71d15b9 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,71 @@ 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<()> { + // NOTE: By default bootc runs in an unshared mount namespace; + // this sets up our /runto actually be the same as the host/run + // so the umount (at the end of this function) actually affects the host + // + // Similar operation is performed in `prepare_soft_reboot_composefs` + 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 does not exist"); + 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!("Cleared soft reboot queued state"); + + 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<&str>, + 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 +91,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()?; @@ -68,6 +121,8 @@ pub(crate) async fn prepare_soft_reboot_composefs( setup_root(args)?; + println!("Soft reboot setup complete"); + if reboot { // Replacing the current process should be fine as we restart userspace anyway let err = Command::new("systemctl").arg("soft-reboot").exec(); 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..d46bffce5 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -10,6 +10,7 @@ use crate::{ bootc_composefs::{ boot::BootType, repo::get_imgref, + selinux::are_selinux_policies_compatible, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ @@ -59,6 +60,13 @@ pub(crate) struct ComposefsCmdline { pub digest: Box, } +/// Information about a deployment for soft reboot comparison +struct DeploymentBootInfo<'a> { + boot_digest: &'a str, + full_cmdline: &'a Cmdline<'a>, + verity: &'a str, +} + impl ComposefsCmdline { pub(crate) fn new(s: &str) -> Self { let (insecure, digest_str) = s @@ -79,6 +87,17 @@ impl std::fmt::Display for ComposefsCmdline { } } +/// The JSON schema for staged deployment information +/// stored in /run/composefs/staged-deployment +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct StagedDeployment { + /// The id (verity hash of the EROFS image) of the staged deployment + pub(crate) depl_id: String, + /// Whether to finalize this staged deployment on reboot or not + /// This also maps to `download_only` field in `BootEntry` + 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(); @@ -365,7 +384,7 @@ fn set_soft_reboot_capability( storage: &Storage, host: &mut Host, bls_entries: Option>, - cmdline: &ComposefsCmdline, + booted_cmdline: &ComposefsCmdline, ) -> Result<()> { let booted = host.require_composefs_booted()?; @@ -381,10 +400,10 @@ fn set_soft_reboot_capability( // vector to check for existence of an entry bls_entries.extend(staged_entries); - set_reboot_capable_type1_deployments(cmdline, host, bls_entries) + set_reboot_capable_type1_deployments(storage, booted_cmdline, host, bls_entries) } - BootType::Uki => set_reboot_capable_uki_deployments(storage, cmdline, host), + BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host), } } @@ -424,6 +443,7 @@ fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool { #[context("Setting soft reboot capability for Type1 entries")] fn set_reboot_capable_type1_deployments( + storage: &Storage, booted_cmdline: &ComposefsCmdline, host: &mut Host, bls_entries: Vec, @@ -439,7 +459,13 @@ fn set_reboot_capable_type1_deployments( let booted_bls_entry = find_bls_entry(&*booted_cmdline.digest, &bls_entries)? .ok_or_else(|| anyhow::anyhow!("Booted BLS entry not found"))?; - let booted_cmdline = booted_bls_entry.get_cmdline()?; + let booted_full_cmdline = booted_bls_entry.get_cmdline()?; + + let booted_info = DeploymentBootInfo { + boot_digest: booted_boot_digest, + full_cmdline: booted_full_cmdline, + verity: &booted_cmdline.digest, + }; for depl in host .status @@ -448,46 +474,64 @@ fn set_reboot_capable_type1_deployments( .chain(host.status.rollback.iter_mut()) .chain(host.status.other_deployments.iter_mut()) { - let entry = find_bls_entry(&depl.require_composefs()?.verity, &bls_entries)? + let depl_verity = &depl.require_composefs()?.verity; + + let entry = find_bls_entry(&depl_verity, &bls_entries)? .ok_or_else(|| anyhow::anyhow!("Entry not found"))?; let depl_cmdline = entry.get_cmdline()?; - depl.soft_reboot_capable = is_soft_rebootable( - depl.composefs_boot_digest()?, - booted_boot_digest, - depl_cmdline, - booted_cmdline, - ); + let target_info = DeploymentBootInfo { + boot_digest: depl.composefs_boot_digest()?, + full_cmdline: depl_cmdline, + verity: &depl_verity, + }; + + depl.soft_reboot_capable = + is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?; } Ok(()) } +/// Determines whether a soft reboot can be performed between the currently booted +/// deployment and a target deployment. +/// +/// # Arguments +/// +/// * `storage` - The bootc storage backend +/// * `booted_cmdline` - The composefs command line parameters of the currently booted deployment +/// * `booted` - Boot information for the currently booted deployment +/// * `target` - Boot information for the target deployment fn is_soft_rebootable( - depl_boot_digest: &str, - booted_boot_digest: &str, - depl_cmdline: &Cmdline, - booted_cmdline: &Cmdline, -) -> bool { - if depl_boot_digest != booted_boot_digest { + storage: &Storage, + booted_cmdline: &ComposefsCmdline, + booted: &DeploymentBootInfo, + target: &DeploymentBootInfo, +) -> Result { + if target.boot_digest != booted.boot_digest { tracing::debug!("Soft reboot not allowed due to kernel skew"); - return false; + return Ok(false); } - if depl_cmdline.as_bytes().len() != booted_cmdline.as_bytes().len() { + if target.full_cmdline.as_bytes().len() != booted.full_cmdline.as_bytes().len() { tracing::debug!("Soft reboot not allowed due to differing cmdline"); - return false; + return Ok(false); } - return compare_cmdline_skip_cfs(depl_cmdline, booted_cmdline) - && compare_cmdline_skip_cfs(booted_cmdline, depl_cmdline); + let cmdline_eq = compare_cmdline_skip_cfs(target.full_cmdline, booted.full_cmdline) + && compare_cmdline_skip_cfs(booted.full_cmdline, target.full_cmdline); + + let selinux_compatible = + are_selinux_policies_compatible(storage, booted_cmdline, target.verity)?; + + return Ok(cmdline_eq && selinux_compatible); } #[context("Setting soft reboot capability for UKI deployments")] fn set_reboot_capable_uki_deployments( storage: &Storage, - cmdline: &ComposefsCmdline, + booted_cmdline: &ComposefsCmdline, host: &mut Host, ) -> Result<()> { let booted = host @@ -499,10 +543,16 @@ fn set_reboot_capable_uki_deployments( // Since older booted systems won't have the boot digest for UKIs let booted_boot_digest = match booted.composefs_boot_digest() { Ok(d) => d, - Err(_) => &compute_store_boot_digest_for_uki(storage, &cmdline.digest)?, + Err(_) => &compute_store_boot_digest_for_uki(storage, &booted_cmdline.digest)?, }; - let booted_cmdline = get_uki_cmdline(storage, &booted.require_composefs()?.verity)?; + let booted_full_cmdline = get_uki_cmdline(storage, &booted_cmdline.digest)?; + + let booted_info = DeploymentBootInfo { + boot_digest: booted_boot_digest, + full_cmdline: &booted_full_cmdline, + verity: &booted_cmdline.digest, + }; for deployment in host .status @@ -511,23 +561,24 @@ fn set_reboot_capable_uki_deployments( .chain(host.status.rollback.iter_mut()) .chain(host.status.other_deployments.iter_mut()) { + let depl_verity = &deployment.require_composefs()?.verity; + // Since older booted systems won't have the boot digest for UKIs let depl_boot_digest = match deployment.composefs_boot_digest() { Ok(d) => d, - Err(_) => &compute_store_boot_digest_for_uki( - storage, - &deployment.require_composefs()?.verity, - )?, + Err(_) => &compute_store_boot_digest_for_uki(storage, depl_verity)?, }; let depl_cmdline = get_uki_cmdline(storage, &deployment.require_composefs()?.verity)?; - deployment.soft_reboot_capable = is_soft_rebootable( - depl_boot_digest, - booted_boot_digest, - &depl_cmdline, - &booted_cmdline, - ); + let target_info = DeploymentBootInfo { + boot_digest: depl_boot_digest, + full_cmdline: &depl_cmdline, + verity: depl_verity, + }; + + deployment.soft_reboot_capable = + is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?; } Ok(()) @@ -554,7 +605,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 +641,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 +665,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..978a45360 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,10 +1,11 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use cap_std_ext::cap_std::fs::Dir; +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 +16,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 +210,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 +284,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 +303,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<()> { + serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from) + }, + ) + .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 +372,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 567ba8ddc..0464e42aa 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, }, } @@ -1836,7 +1839,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()? { @@ -1844,9 +1851,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_deref(), + SoftRebootMode::Required, + reboot, + ) + .await } } } diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index f0ce7cc94..26220c5c3 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -311,6 +311,10 @@ impl BootEntry { )) } + /// Get the boot digest for this deployment + /// This is the + /// - SHA256SUM of kernel + initrd for Type1 booted deployments + /// - SHA256SUM of UKI for Type2 booted deployments pub(crate) fn composefs_boot_digest(&self) -> Result<&String> { self.require_composefs()? .boot_digest