diff --git a/src/bootupd.rs b/src/bootupd.rs index b7828691..c2baf991 100644 --- a/src/bootupd.rs +++ b/src/bootupd.rs @@ -24,6 +24,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use clap::crate_version; use fn_error_context::context; use libc::mode_t; +use openat_ext::OpenatDirExt; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::BTreeMap; @@ -212,6 +213,20 @@ pub(crate) fn get_components() -> Components { get_components_impl(false) } +/// Return available components +#[context("Get available components")] +pub(crate) fn get_available_components(sysroot: &openat::Dir) -> Result { + let mut avail = BTreeMap::new(); + + for (name, component) in get_components_impl(false) { + if crate::component::get_component_update(sysroot, component.as_ref())?.is_some() { + avail.insert(name, component); + } + } + + Ok(avail) +} + pub(crate) fn generate_update_metadata(sysroot_path: &str) -> Result<()> { // create bootupd update dir which will save component metadata files for both components let updates_dir = Path::new(sysroot_path).join(crate::model::BOOTUPD_UPDATES_DIR); @@ -639,6 +654,36 @@ pub(crate) fn client_run_validate() -> Result<()> { Ok(()) } +pub(crate) fn client_run_remove_component(component_name: &str) -> Result<()> { + let sysroot = openat::Dir::open("/").context("opening sysroot directory /")?; + + let components = get_available_components(&sysroot)?; + + // Find the component (ignore ASCII case) + if let Some((name, component)) = components + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(component_name)) + { + let comp_dirname = crate::component::component_updatedirname(component.as_ref()); + + // Construct the path relative to sysroot: BOOTUPD_UPDATES_DIR/.json + let path = comp_dirname.with_extension("json"); + + // Remove the file using the sysroot Dir handle + sysroot.remove_file_optional(&path).map_err(|e| { + if let Some(libc::EACCES) = e.raw_os_error() { + anyhow::anyhow!("Permission denied: Cannot remove component file at /{}. Try running with sudo.", path.display()) + } else { + anyhow::anyhow!(e).context(format!("Failed to remove component file /{}", path.display())) + } + })?; + println!("Removed component '{}'", name); + } else { + anyhow::bail!("Could not find component '{}'", component_name); + } + Ok(()) +} + #[context("Migrating to a static GRUB config")] pub(crate) fn client_run_migrate_static_grub_config() -> Result<()> { // Did we already complete the migration? diff --git a/src/cli/bootupctl.rs b/src/cli/bootupctl.rs index 1c30b24e..790ba9f6 100644 --- a/src/cli/bootupctl.rs +++ b/src/cli/bootupctl.rs @@ -1,5 +1,5 @@ use crate::bootupd; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use log::LevelFilter; @@ -65,6 +65,8 @@ pub enum CtlVerb { about = "Migrate a system to a static GRUB config" )] MigrateStaticGrubConfig, + #[clap(name = "remove-component", about = "Remove component")] + RemoveComponent(RemoveComponentOpts), } #[derive(Debug, Parser)] @@ -95,6 +97,13 @@ pub struct AdoptAndUpdateOpts { with_static_config: bool, } +#[derive(Debug, Parser)] +pub struct RemoveComponentOpts { + /// Component name to be removed + #[clap(value_parser)] + component: String, +} + impl CtlCommand { /// Run CLI application. pub fn run(self) -> Result<()> { @@ -103,6 +112,7 @@ impl CtlCommand { CtlVerb::Update => Self::run_update(), CtlVerb::AdoptAndUpdate(opts) => Self::run_adopt_and_update(opts), CtlVerb::Validate => Self::run_validate(), + CtlVerb::RemoveComponent(opts) => Self::run_remove_component(opts), CtlVerb::Backend(CtlBackend::Generate(opts)) => { super::bootupd::DCommand::run_generate_meta(opts) } @@ -151,6 +161,11 @@ impl CtlCommand { bootupd::client_run_validate() } + /// Runner for `remove-component` verb. + fn run_remove_component(opts: RemoveComponentOpts) -> Result<()> { + bootupd::client_run_remove_component(&opts.component) + } + /// Runner for `migrate-static-grub-config` verb. fn run_migrate_static_grub_config() -> Result<()> { ensure_running_in_systemd()?; @@ -214,20 +229,23 @@ fn ensure_running_in_systemd() -> Result<()> { /// If running in container, just print the available payloads fn run_status_in_container(json_format: bool) -> Result<()> { - let all_components = crate::bootupd::get_components(); - if all_components.is_empty() { - return Ok(()); - } - let avail: Vec<_> = all_components.keys().cloned().collect(); + let sysroot = openat::Dir::open("/").context("opening sysroot directory /")?; + + let avail: Vec<_> = crate::bootupd::get_available_components(&sysroot)? + .into_keys() + .collect(); + if json_format { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); - let output: serde_json::Value = serde_json::json!({ + let output = serde_json::json!({ "components": avail }); serde_json::to_writer(&mut stdout, &output)?; } else { - println!("Available components: {}", avail.join(" ")); + if !avail.is_empty() { + println!("Available components: {}", avail.join(" ")); + } } Ok(()) } diff --git a/tests/kola/test-bootupd b/tests/kola/test-bootupd index 3d557267..7f0c580f 100755 --- a/tests/kola/test-bootupd +++ b/tests/kola/test-bootupd @@ -141,7 +141,7 @@ if [ -d /sys/firmware/efi ]; then update_metadata_move EFI-bak EFI # Should succeed if BIOS metadata is missing - rm -f ${bootupdir}/BIOS.json + bootupctl remove-component bios bootupctl update | tee out.txt assert_file_has_content out.txt 'Adopted and updated: EFI:' assert_not_file_has_content out.txt 'Adopted and updated: BIOS:' @@ -156,7 +156,7 @@ else update_metadata_move BIOS-bak BIOS # Should succeed if EFI metadata is missing - rm -f ${bootupdir}/EFI.json + bootupctl remove-component efi bootupctl update | tee out.txt assert_file_has_content out.txt 'Adopted and updated: BIOS:' assert_not_file_has_content out.txt 'Adopted and updated: EFI:' diff --git a/tests/tests/bootupctl-status-in-bootc.sh b/tests/tests/bootupctl-status-in-bootc.sh index 180163d0..3f5ca22b 100755 --- a/tests/tests/bootupctl-status-in-bootc.sh +++ b/tests/tests/bootupctl-status-in-bootc.sh @@ -7,22 +7,46 @@ if [ ! -d "/sysroot/ostree/repo/" ]; then exit 100 fi +components_text_x86_64='Available components: BIOS EFI' +components_json_x86_64='{"components":["BIOS","EFI"]}' + +components_text_aarch64='Available components: EFI' +components_json_aarch64='{"components":["EFI"]}' + +none_components_json='{"components":[]}' + # check if running in container if [ "$container" ] || [ -f /run/.containerenv ] || [ -f /.dockerenv ]; then arch="$(uname --machine)" - if [[ "${arch}" == "x86_64" ]]; then - components_text='Available components: BIOS EFI' - components_json='{"components":["BIOS","EFI"]}' - else - # Assume aarch64 for now - components_text='Available components: EFI' - components_json='{"components":["EFI"]}' + output_text=$(bootupctl status | tr -d '\r') + output_json=$(bootupctl status --json) + + if [ "${arch}" == "x86_64" ]; then + [ "${components_text_x86_64}" == "${output_text}" ] + [ "${components_json_x86_64}" == "${output_json}" ] + # test with no BIOS.json + bootupctl remove-component bios + output_text=$(bootupctl status | tr -d '\r') + output_json=$(bootupctl status --json) fi - output=$(bootupctl status | tr -d '\r') - [ "${components_text}" == "${output}" ] - output=$(bootupctl status --json) - [ "${components_json}" == "${output}" ] + if [ "${arch}" == "x86_64" ] || [ "${arch}" == "aarch64" ]; then + [ "${components_text_aarch64}" == "${output_text}" ] + [ "${components_json_aarch64}" == "${output_json}" ] + fi + + # test with no components + bootupctl remove-component efi + output_text=$(bootupctl status | tr -d '\r') + output_json=$(bootupctl status --json) + [ -z "${output_text}" ] + [ "${none_components_json}" == "${output_json}" ] + + # remove none existing component + if bootupctl remove-component test 2>err.txt; then + echo "unexpectedly passed remove none existing component" + exit 1 + fi else echo "Skip running as not in container" fi