From 709f0c45cbdc59d0a2b378a7ee379dc66f3ebf8b Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Tue, 27 Oct 2026 13:07:04 +0100 Subject: [PATCH 1/5] aboot: Require aboot-deploy in images at install/update Without this, later updates will fail, so ensure we don't get into this bad state. Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 9 +++++++++ crates/lib/src/bootc_composefs/boot.rs | 4 ++++ crates/lib/src/bootc_composefs/update.rs | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index a0c93d0d1..66f5c4fbb 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -52,6 +52,15 @@ use super::{ const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; +/// Require update and rollback tooling in every installed or staged aboot image. +pub(crate) fn require_aboot_deploy_in_image(root: &Dir) -> Result<()> { + ensure!( + crate::utils::have_executable_in_root(root, "aboot-deploy")?, + "Target image must contain aboot-deploy for aboot updates and rollbacks" + ); + Ok(()) +} + /// Logical A/B slot observed from the running kernel command line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Slot { diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index d3a466775..a82f44e88 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2871,6 +2871,10 @@ pub(crate) async fn setup_composefs_boot( .context("Failed to mount composefs image")?; let image_root = Dir::reopen_dir(&composefs_mnt_fd)?; + if boot_type == BootType::Aboot { + super::aboot::require_aboot_deploy_in_image(&image_root)?; + } + let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?; validate_bootloader(entry, postfetch.detected_bootloader)?; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 7fc373d9b..c95be4a06 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -390,6 +390,10 @@ pub(crate) async fn do_upgrade( .context("Failed to mount composefs image")?, )?; + if boot_type == BootType::Aboot { + aboot::require_aboot_deploy_in_image(&mounted_fs)?; + } + // Check if three way etc merge is possible without conflicts let new_etc = mounted_fs .open_dir("etc") From b4c02def8ae5a6cf041a74fb41ba809e9d29d7fd Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Tue, 27 Oct 2026 15:52:44 +0100 Subject: [PATCH 2/5] aboot: Allow replacing failed updates after reboot Retaining an attempted aboot deployment currently blocks further updates and switches after a failed flash or firmware fallback. Instead we allow an explicit update or switch to discard it after reboot, preserving the selected image source and refreshing status before checking deployment collisions. Keep the same-boot guard and leave failed attempts disarmed across ordinary reboots. Rollback can also discard an older failed attempt when a valid rollback deployment remains available. Document recovery and extend the unit and VM tests to cover failed finalization, repeated reboots without reflashing, and explicit retry. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 96 ++++++++++++++++--- crates/lib/src/bootc_composefs/switch.rs | 12 ++- crates/lib/src/bootc_composefs/update.rs | 22 ++++- .../bootc-experimental-composefs-aboot.7.md | 16 +++- tmt/tests/booted/aboot_testlib.py | 4 +- .../booted/test-aboot-update-rollback.py | 33 ++++++- 6 files changed, 157 insertions(+), 26 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index 66f5c4fbb..13998080d 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -27,7 +27,8 @@ //! invalidates the non-booted slot mapping, and invokes aboot-deploy. On the next boot, //! observing the candidate consumes `pending`, its staged payload directory, and //! `attempted`. Observing another deployment retains them without automatically retrying -//! the write. An unattempted pending update is projected back into `/run` and rearmed +//! the write; an explicit update or switch can discard them after that reboot. +//! An unattempted pending update is projected back into `/run` and rearmed //! unless it was staged download-only. use anyhow::{Context, Result, ensure}; @@ -365,18 +366,34 @@ impl<'a> AbootState<'a> { } pub(crate) fn discard_pending(&self) -> Result<()> { - ensure!( - self.read_attempted()?.is_none(), - "An attempted aboot update has not been reconciled" - ); + if let Some(attempted) = self.read_attempted()? { + ensure!( + attempted.boot_id != boot_id()?, + "Cannot discard an aboot update attempted during this boot" + ); + } + // Remove pending before attempted so interrupted cleanup cannot rearm the payload. if let Some(pending) = self.read_pending()? { self.remove_state_file(ABOOT_PENDING_FNAME)?; self.remove_artifacts(&pending.depl_id)?; } + self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?; remove_staged_deployment()?; Ok(()) } + /// Discard a failed attempt only when an explicit update replaces it after reboot. + pub(crate) fn discard_failed_attempt(&self, booted: &str) -> Result { + let Some(attempted) = self.read_attempted()? else { + return Ok(false); + }; + if attempted.depl_id == booted { + return Ok(false); + } + self.discard_pending()?; + Ok(true) + } + /// Mark an update attempted before invalidating a slot or writing partitions. pub(crate) fn record_attempt(&mut self, deployment: &str) -> Result<()> { ensure!( @@ -767,15 +784,14 @@ mod tests { boot_id: "old-boot".into(), }, )?; - assert!(matches!( - state.reconcile_state("booted", "old-boot")?, - ReconcileAction::None - )); - assert!(matches!( - state.reconcile_state("booted", "new-boot")?, - ReconcileAction::None - )); - assert!(state.read_pending()?.is_some()); + for current_boot in ["old-boot", "new-boot", "another-boot"] { + assert!(matches!( + state.reconcile_state("booted", current_boot)?, + ReconcileAction::None + )); + assert_eq!(state.read_pending()?, Some(pending.clone())); + assert_eq!(state.read_attempted()?.unwrap().boot_id, "old-boot"); + } assert!(root.try_exists(&artifact_dir)?); assert!(matches!( state.reconcile_state(&digest, "new-boot")?, @@ -787,6 +803,58 @@ mod tests { Ok(()) } + #[test] + fn replace_failed_attempt_after_reboot() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + let artifact_dir = format!("{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}"); + deployment(&root, &digest, "aboot")?; + let mut state = AbootState::open(&root)?; + let pending = state.stage_artifacts(&digest, false, b"failed", None)?; + state.write_pending(&pending)?; + state.record_attempt(&digest)?; + + assert!(state.discard_failed_attempt("booted").is_err()); + assert!(state.discard_pending().is_err()); + assert_eq!(state.read_pending()?, Some(pending)); + assert!(root.try_exists(&artifact_dir)?); + + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: digest.clone(), + boot_id: "previous-boot".into(), + }, + )?; + // Leave successful attempts for normal boot reconciliation. + assert!(!state.discard_failed_attempt(&digest)?); + assert!(state.read_attempted()?.is_some()); + assert!(state.discard_failed_attempt("booted")?); + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + assert!(!root.try_exists(&artifact_dir)?); + + // The same image can be staged and attempted again explicitly. + let pending = state.stage_artifacts(&digest, false, b"retry", None)?; + state.write_pending(&pending)?; + state.record_attempt(&digest)?; + assert_eq!(state.read_attempted()?.unwrap().boot_id, boot_id()?); + + // Rollback can also discard an older failed attempt. + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: digest, + boot_id: "previous-boot".into(), + }, + )?; + state.discard_pending()?; + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + assert!(!root.try_exists(&artifact_dir)?); + Ok(()) + } + #[test] fn rollback_and_deletion() -> Result<()> { let root = tempdir(ambient_authority())?; diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index ee5f622d3..f838fcbd7 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -5,9 +5,9 @@ use crate::{ bootc_composefs::{ status::get_composefs_status, update::{ - DoUpgradeOpts, UpdateAction, apply_upgrade_from_downloaded, do_upgrade, - ensure_delta_source_present, is_image_pulled, lookup_config_splitstream, - validate_update, + DoUpgradeOpts, UpdateAction, apply_upgrade_from_downloaded, + discard_failed_aboot_update, do_upgrade, ensure_delta_source_present, is_image_pulled, + lookup_config_splitstream, validate_update, }, }, cli::{SwitchOpts, imgref_for_switch}, @@ -22,7 +22,7 @@ pub(crate) async fn switch_composefs( booted_cfs: &BootedComposefs, ) -> Result<()> { // TODO: Handle in-place - let host = get_composefs_status(storage, booted_cfs) + let mut host = get_composefs_status(storage, booted_cfs) .await .context("Getting composefs deployment status")?; @@ -50,6 +50,10 @@ pub(crate) async fn switch_composefs( delta.validate_image_reference(&target)?; } + if discard_failed_aboot_update(storage, &host)? { + host = get_composefs_status(storage, booted_cfs).await?; + } + let new_spec = { let mut new_spec = host.spec.clone(); new_spec.image = Some(target.clone()); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c95be4a06..977a77256 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -270,6 +270,15 @@ fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> { Ok(()) } +/// Drop a failed aboot candidate before constructing the next update's target and state. +pub(crate) fn discard_failed_aboot_update(storage: &Storage, host: &Host) -> Result { + let booted = host.require_composefs_booted()?; + if booted.boot_type != BootType::Aboot { + return Ok(false); + } + aboot::AbootState::open(&storage.physical_root)?.discard_failed_attempt(&booted.verity) +} + /// This is just an intersection of SwitchOpts and UpgradeOpts pub(crate) struct DoUpgradeOpts<'a> { pub(crate) apply: bool, @@ -602,11 +611,20 @@ pub(crate) async fn upgrade_composefs( "Starting composefs upgrade operation" ); - let host = get_composefs_status(storage, composefs) + let mut host = get_composefs_status(storage, composefs) .await .context("Getting composefs deployment status")?; - let current_image = host.spec.image.as_ref(); + // Preserve the selected source when discarding a failed staged switch. + let update_image = host.spec.image.clone(); + if !opts.check + && !opts.download_opts.from_downloaded + && discard_failed_aboot_update(storage, &host)? + { + host = get_composefs_status(storage, composefs).await?; + } + + let current_image = update_image.as_ref(); // Handle --tag: derive target from current image + new tag let derived_image = if let Some(ref tag) = opts.tag { diff --git a/docs/src/bootc-experimental-composefs-aboot.7.md b/docs/src/bootc-experimental-composefs-aboot.7.md index bd4d2ab9e..ca0b1156a 100644 --- a/docs/src/bootc-experimental-composefs-aboot.7.md +++ b/docs/src/bootc-experimental-composefs-aboot.7.md @@ -86,9 +86,19 @@ requires an unambiguous booted slot and calls `aboot-deploy` to flash the inacti On the next boot, bootc records the observed `androidboot.slot_suffix` kernel commandline option and reconciles the attempted deployment. The previous slot becomes the rollback deployment; `bootc rollback` queues it through `aboot-deploy --rollback`. `bootc status` -indicates when a rollback is queued. A failed boot is **not** automatically retried: bootc -retains the attempted update for inspection and will not stage another update until that -state is resolved. +indicates when a rollback is queued. + +After a failed flash or firmware fallback, bootc retains the attempted update for +inspection. Bootc does **not** automatically retry the flash, including on subsequent reboots. +Starting with the first boot after the failed attempt, `bootc upgrade` or +`bootc switch` discards that staged update before preparing a new one. This also +allows explicitly retrying the same image. `bootc upgrade --check` leaves the failed +attempt untouched. During the boot that recorded the attempt, replacing or discarding +it is blocked because its boot outcome is not yet known. + +`bootc rollback` also discards the failed pending update when a valid rollback +deployment is available. A failed flash may have overwritten the inactive slot, +so discarding the attempt does not necessarily restore a rollback deployment. The firmware's slot-selection and success-marking behavior is outside bootc. For example, the ukiboot enables `ukiboot-set-success.service` to mark a successful boot. Validate that diff --git a/tmt/tests/booted/aboot_testlib.py b/tmt/tests/booted/aboot_testlib.py index 07c94e05d..40e429fa9 100644 --- a/tmt/tests/booted/aboot_testlib.py +++ b/tmt/tests/booted/aboot_testlib.py @@ -13,7 +13,7 @@ def status(): return json.loads(run("bootc", "status", "--json"))["status"] -def verify_boot(slot, version): +def verify_boot(slot, version, *, staged=False): assert run("systemctl", "is-enabled", "ukiboot-set-success.service") == "enabled" for service in ("ukiboot-set-success.service", "bootc-aboot-reconcile.service"): for attempt in range(60): @@ -30,7 +30,7 @@ def verify_boot(slot, version): assert booted["ostree"] is None, booted assert booted["composefs"]["bootType"] == "Aboot", booted assert run("findmnt", "--noheadings", "--output", "FSTYPE", "--target", "/sysroot") == "ext4" - assert st["staged"] is None, st + assert (st["staged"] is not None) == staged, st assert not st["rollbackQueued"], st assert Path("/usr/share/bootc-aboot-test-version").read_text().strip() == str(version) assert f"androidboot.slot_suffix=_{slot}" in Path("/proc/cmdline").read_text().split() diff --git a/tmt/tests/booted/test-aboot-update-rollback.py b/tmt/tests/booted/test-aboot-update-rollback.py index 7ff790827..3402aed1e 100644 --- a/tmt/tests/booted/test-aboot-update-rollback.py +++ b/tmt/tests/booted/test-aboot-update-rollback.py @@ -18,6 +18,9 @@ from aboot_testlib import run, status, verify_boot ORIGINAL = Path("/var/tmp/bootc-aboot-original.json") +ABOOT_CONFIG = Path("/etc/aboot.cfg") +ABOOT_CONFIG_BACKUP = Path("/var/tmp/bootc-aboot-original.cfg") +ABOOT_STATE = Path("/sysroot/state/boot/aboot") def main(): @@ -33,8 +36,36 @@ def main(): assert staged["staged"]["image"]["image"]["image"] == target, staged assert staged["staged"]["composefs"]["bootType"] == "Aboot", staged assert staged["staged"]["composefs"]["verity"] != initial["booted"]["composefs"]["verity"], staged + # Make aboot-deploy fail while reading its configuration, before flashing. + original_config = ABOOT_CONFIG.read_bytes() + ABOOT_CONFIG_BACKUP.write_bytes(original_config) + ABOOT_CONFIG.write_bytes(original_config + b"\nBOOTC_TEST_FAIL_DEPLOY=1\n") subprocess.run(["tmt-reboot"], check=True) elif count == 1: + ABOOT_CONFIG.write_bytes(ABOOT_CONFIG_BACKUP.read_bytes()) + ABOOT_CONFIG_BACKUP.unlink() + failed = verify_boot("a", 1, staged=True) + attempted = (ABOOT_STATE / "attempted").read_bytes() + pending = (ABOOT_STATE / "pending").read_bytes() + attempt = json.loads(attempted) + assert attempt["depl_id"] == failed["staged"]["composefs"]["verity"], attempt + assert attempt["boot_id"] != Path("/proc/sys/kernel/random/boot_id").read_text().strip(), attempt + assert not (ABOOT_STATE / "slots/b").exists() + run("bootc", "upgrade", "--check") + assert (ABOOT_STATE / "attempted").read_bytes() == attempted + assert (ABOOT_STATE / "pending").read_bytes() == pending + # Even with the configuration repaired, another reboot must not retry it. + subprocess.run(["tmt-reboot"], check=True) + elif count == 2: + failed = verify_boot("a", 1, staged=True) + assert (ABOOT_STATE / "attempted").exists() + run("bootc", "upgrade") + retried = status() + assert retried["staged"]["image"]["image"]["image"] == target, retried + assert retried["staged"]["composefs"]["verity"] == failed["staged"]["composefs"]["verity"], retried + assert not (ABOOT_STATE / "attempted").exists() + subprocess.run(["tmt-reboot"], check=True) + elif count == 3: original = json.loads(ORIGINAL.read_text()) updated = verify_boot("b", 2) assert updated["booted"]["image"]["image"]["image"] == target, updated @@ -44,7 +75,7 @@ def main(): run("bootc", "rollback") assert status()["rollbackQueued"] subprocess.run(["tmt-reboot"], check=True) - elif count == 2: + elif count == 4: original = json.loads(ORIGINAL.read_text()) restored = verify_boot("a", 1) assert restored["booted"]["image"] == original["image"], restored From 73eb71cb8d4165fe6d64f67fd85950809fd848fa Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 28 Oct 2026 10:04:39 +0100 Subject: [PATCH 3/5] aboot: Detect the host bootloader at runtime We're currently always showing "bootloader None" in the aboot entires, this is not right for ukiboot hosts. Read BOOT_TYPE from the running system's /etc/aboot.cfg instead, reporting ukiboot for ukiboot configurations and none for other values. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 56 ++++++++++++ crates/lib/src/bootc_composefs/status.rs | 88 ++++++++++++++++--- tmt/tests/booted/aboot_testlib.py | 1 + .../booted/test-aboot-update-rollback.py | 2 + 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index 13998080d..f4b5fdb5b 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -44,6 +44,7 @@ use crate::composefs_consts::{ ABOOT_ARTIFACT_DIR, ABOOT_ATTEMPTED_FNAME, ABOOT_IMAGE_FNAME, ABOOT_PENDING_FNAME, ABOOT_ROLLBACK_FNAME, ABOOT_STATE_DIR, ABOOT_VBMETA_FNAME, STATE_DIR_RELATIVE, }; +use crate::spec::Bootloader; use super::{ boot::BootType, @@ -52,6 +53,33 @@ use super::{ }; const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; +const ABOOT_CONFIG: &str = "/etc/aboot.cfg"; + +/// Determine the host bootloader from the running system's aboot configuration. +pub(crate) fn get_bootloader() -> Result { + let config = match std::fs::read_to_string(ABOOT_CONFIG) { + Ok(config) => config, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::None), + Err(err) => return Err(err).with_context(|| format!("Reading {ABOOT_CONFIG}")), + }; + Ok(bootloader_from_config(&config)) +} + +fn bootloader_from_config(config: &str) -> Bootloader { + // Match aboot_config_load(): trim keys and values, keep the last assignment, + // and treat quotes and inline comments literally rather than as shell syntax. + let boot_type = config + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once('=')) + .filter(|(key, _)| key.trim_ascii() == "BOOT_TYPE") + .map(|(_, value)| value.trim_ascii()) + .next_back(); + match boot_type { + Some("ukiboot") => Bootloader::Ukiboot, + _ => Bootloader::None, + } +} /// Require update and rollback tooling in every installed or staged aboot image. pub(crate) fn require_aboot_deploy_in_image(root: &Dir) -> Result<()> { @@ -593,6 +621,34 @@ mod tests { use crate::composefs_consts::STATE_DIR_RELATIVE; use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir}; + #[test] + fn bootloader_config() { + for (config, expected) in [ + ("", Bootloader::None), + ( + "PARTITION_A=/dev/disk/by-partlabel/boot_a\n", + Bootloader::None, + ), + ("BOOT_TYPE=aboot\n", Bootloader::None), + ("BOOT_TYPE=unknown\n", Bootloader::None), + ("BOOT_TYPE=\n", Bootloader::None), + ("BOOT_TYPE=ukiboot", Bootloader::Ukiboot), + (" \tBOOT_TYPE \t= \tukiboot \t\r\n", Bootloader::Ukiboot), + ( + "#BOOT_TYPE=ukiboot\n #BOOT_TYPE=ukiboot\n", + Bootloader::None, + ), + ("BOOT_TYPE\nCMDLINE=a=b\n", Bootloader::None), + ("BOOT_TYPE=ukiboot\nBOOT_TYPE=aboot\n", Bootloader::None), + ("BOOT_TYPE=aboot\nBOOT_TYPE=ukiboot\n", Bootloader::Ukiboot), + ("BOOT_TYPE=\"ukiboot\"\n", Bootloader::None), + ("BOOT_TYPE='ukiboot'\n", Bootloader::None), + ("BOOT_TYPE=ukiboot # comment\n", Bootloader::None), + ] { + assert_eq!(bootloader_from_config(config), expected, "{config:?}"); + } + } + fn deployment(root: &Dir, digest: &str, boot_type: &str) -> Result<()> { let path = format!("{STATE_DIR_RELATIVE}/{digest}"); root.create_dir_all(&path)?; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe..b0efe2b1a 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -953,7 +953,12 @@ fn read_staged_deployment() -> Result> { .map(Some) } -fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> Result { +fn aboot_entry( + storage: &Storage, + verity: &str, + missing_verity_allowed: bool, + bootloader: Bootloader, +) -> Result { let origin = read_origin(&storage.physical_root, verity)? .ok_or_else(|| anyhow::anyhow!("No origin file for aboot deployment {verity}"))?; let entry = boot_entry_from_composefs_deployment( @@ -961,7 +966,7 @@ fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> origin, verity, missing_verity_allowed, - Bootloader::None, + bootloader, )?; ensure!( entry.require_composefs()?.boot_type == BootType::Aboot, @@ -970,13 +975,17 @@ fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> Ok(entry) } -fn composefs_aboot_status(storage: &Storage, cmdline: &ComposefsCmdline) -> Result { +fn composefs_aboot_status( + storage: &Storage, + cmdline: &ComposefsCmdline, + bootloader: Bootloader, +) -> Result { let state = aboot::AbootState::open(&storage.physical_root)?; let staged = match state.read_pending()? { Some(pending) => Some(pending.staged()), None => read_staged_deployment()?, }; - composefs_aboot_status_from(storage, &state, cmdline, staged.as_ref()) + composefs_aboot_status_from(storage, &state, cmdline, staged.as_ref(), bootloader) } fn composefs_aboot_status_from( @@ -984,6 +993,7 @@ fn composefs_aboot_status_from( state: &aboot::AbootState<'_>, cmdline: &ComposefsCmdline, staged: Option<&StagedDeployment>, + bootloader: Bootloader, ) -> Result { let mut host = Host::new(HostSpec { image: None, @@ -994,10 +1004,16 @@ fn composefs_aboot_status_from( storage, &cmdline.digest, cmdline.allow_missing_fsverity, + bootloader, )?); if let Some(staged) = staged.filter(|s| s.depl_id != *cmdline.digest) { - let mut entry = aboot_entry(storage, &staged.depl_id, cmdline.allow_missing_fsverity)?; + let mut entry = aboot_entry( + storage, + &staged.depl_id, + cmdline.allow_missing_fsverity, + bootloader, + )?; entry.download_only = staged.finalization_locked; host.status.staged = Some(entry); } @@ -1012,7 +1028,7 @@ fn composefs_aboot_status_from( continue; } seen.push(verity.clone()); - let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity)?; + let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity, bootloader)?; if host.status.rollback.is_none() { host.status.rollback = Some(entry); } else { @@ -1052,7 +1068,7 @@ async fn composefs_deployment_status_from( let booted_composefs_digest = &cmdline.digest; if read_boot_type(&storage.physical_root, booted_composefs_digest)? == Some(BootType::Aboot) { - return composefs_aboot_status(storage, cmdline); + return composefs_aboot_status(storage, cmdline, aboot::get_bootloader()?); } let boot_dir = storage.require_boot_dir()?; @@ -1293,6 +1309,37 @@ mod tests { Ok(()) } + #[test] + fn aboot_status_uses_host_bootloader() -> Result<()> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir("run")?; + let run = root.open_dir("run")?; + let storage = Storage::new_composefs_for_test(&root, &run)?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + let staged_id = "cc".repeat(64); + for digest in [&a, &b, &staged_id] { + write_aboot_origin(&root, digest)?; + } + let mut state = aboot::AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + let cmdline = ComposefsCmdline::new(&a); + let staged = StagedDeployment { + depl_id: staged_id, + finalization_locked: false, + }; + for bootloader in [Bootloader::None, Bootloader::Ukiboot] { + let host = + composefs_aboot_status_from(&storage, &state, &cmdline, Some(&staged), bootloader)?; + assert_eq!(host.list_deployments().len(), 3); + for entry in host.list_deployments() { + assert_eq!(entry.require_composefs()?.bootloader, bootloader); + } + } + Ok(()) + } + #[test] fn aboot_status_from_slots() -> Result<()> { let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; @@ -1310,7 +1357,8 @@ mod tests { aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; let cmdline = ComposefsCmdline::new(&a); - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + let host = + composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?; let booted = host.require_composefs_booted()?; assert_eq!(booted.verity, a); assert_eq!(booted.boot_type, BootType::Aboot); @@ -1329,7 +1377,8 @@ mod tests { assert!(!host.status.rollback_queued); aboot_state.queue_rollback(&b)?; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + let host = + composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?; assert!(host.status.rollback_queued); assert_eq!(host.spec.boot_order, BootOrder::Rollback); aboot_state.clear_rollback()?; @@ -1338,7 +1387,13 @@ mod tests { depl_id: staged_id.clone(), finalization_locked: true, }; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; + let host = composefs_aboot_status_from( + &storage, + &aboot_state, + &cmdline, + Some(&staged), + Bootloader::None, + )?; let staged_entry = host.status.staged.as_ref().unwrap(); assert_eq!(staged_entry.require_composefs()?.verity, staged_id); assert!(staged_entry.download_only); @@ -1356,7 +1411,13 @@ mod tests { depl_id: b.clone(), finalization_locked: false, }; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; + let host = composefs_aboot_status_from( + &storage, + &aboot_state, + &cmdline, + Some(&staged), + Bootloader::None, + )?; assert_eq!( host.status .staged @@ -1369,13 +1430,14 @@ mod tests { assert!(host.status.rollback.is_none()); root.atomic_write(format!("{ABOOT_STATE_DIR}/slots/b"), "invalid")?; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + let host = + composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?; assert!(host.status.rollback.is_none()); assert_eq!(host.list_deployments().len(), 1); let pending = aboot_state.stage_artifacts(&staged_id, true, b"boot", None)?; aboot_state.write_pending(&pending)?; - let host = composefs_aboot_status(&storage, &cmdline)?; + let host = composefs_aboot_status(&storage, &cmdline, Bootloader::None)?; assert!(host.status.staged.as_ref().unwrap().download_only); Ok(()) } diff --git a/tmt/tests/booted/aboot_testlib.py b/tmt/tests/booted/aboot_testlib.py index 40e429fa9..c38db873a 100644 --- a/tmt/tests/booted/aboot_testlib.py +++ b/tmt/tests/booted/aboot_testlib.py @@ -29,6 +29,7 @@ def verify_boot(slot, version, *, staged=False): booted = st["booted"] assert booted["ostree"] is None, booted assert booted["composefs"]["bootType"] == "Aboot", booted + assert booted["composefs"]["bootloader"] == "ukiboot", booted assert run("findmnt", "--noheadings", "--output", "FSTYPE", "--target", "/sysroot") == "ext4" assert (st["staged"] is not None) == staged, st assert not st["rollbackQueued"], st diff --git a/tmt/tests/booted/test-aboot-update-rollback.py b/tmt/tests/booted/test-aboot-update-rollback.py index 3402aed1e..d9ec5b032 100644 --- a/tmt/tests/booted/test-aboot-update-rollback.py +++ b/tmt/tests/booted/test-aboot-update-rollback.py @@ -35,6 +35,7 @@ def main(): assert staged["booted"] == initial["booted"], staged assert staged["staged"]["image"]["image"]["image"] == target, staged assert staged["staged"]["composefs"]["bootType"] == "Aboot", staged + assert staged["staged"]["composefs"]["bootloader"] == "ukiboot", staged assert staged["staged"]["composefs"]["verity"] != initial["booted"]["composefs"]["verity"], staged # Make aboot-deploy fail while reading its configuration, before flashing. original_config = ABOOT_CONFIG.read_bytes() @@ -72,6 +73,7 @@ def main(): assert updated["booted"]["composefs"]["verity"] != original["composefs"]["verity"], updated assert updated["rollback"]["image"] == original["image"], updated assert updated["rollback"]["composefs"]["verity"] == original["composefs"]["verity"], updated + assert updated["rollback"]["composefs"]["bootloader"] == "ukiboot", updated run("bootc", "rollback") assert status()["rollbackQueued"] subprocess.run(["tmt-reboot"], check=True) From 33274b8347e561bf5767b8106e8e306c92cf502b Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 28 Oct 2026 10:05:37 +0100 Subject: [PATCH 4/5] aboot: Reject updates that change the bootloader Android aboot and ukiboot share the Aboot boot type but require different bootloaders. Checking only the boot type allows updates across incompatible layouts. Reuse the install validator against the running host's detected bootloader before staging an update. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 2 +- crates/lib/src/bootc_composefs/update.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a82f44e88..2a16a7520 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2492,7 +2492,7 @@ fn install_ukiboot( Ok(()) } -fn validate_bootloader( +pub(crate) fn validate_bootloader( entry: &ComposefsBootEntry, bootloader: Bootloader, ) -> Result<()> { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 977a77256..ef2530d79 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -385,13 +385,16 @@ pub(crate) async fn do_upgrade( let entry = super::boot::primary_boot_entry(&entries)?; let boot_type = BootType::from(entry); - let booted_type = host.require_composefs_booted()?.boot_type; + let booted = host.require_composefs_booted()?; + let bootloader = booted.bootloader; + let booted_type = booted.boot_type; if boot_type == BootType::Aboot || booted_type == BootType::Aboot { anyhow::ensure!( boot_type == booted_type, "Cannot switch between aboot and non-aboot boot layouts" ); } + super::boot::validate_bootloader(entry, bootloader)?; let mounted_fs = Dir::reopen_dir( &repo From 58ca6d95f9b6c8b24ce95bfa1061fcf91c860976 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 2 Nov 2026 10:15:40 +0100 Subject: [PATCH 5/5] aboot: Retry failed switches to the same image reference A failed aboot update can have the same image reference as the booted (i.e. last working) deployment. In this case switch would consider the spec unchanged and returns without staging the requested retry. We no instead discard the old update and queue a new one. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/switch.rs | 16 +++++- .../booted/test-aboot-update-rollback.py | 57 ++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index f838fcbd7..336a88594 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -50,9 +50,21 @@ pub(crate) async fn switch_composefs( delta.validate_image_reference(&target)?; } - if discard_failed_aboot_update(storage, &host)? { + // There may be an existing failed update with the same name as "target", and + // in that case we don't want the "nothing changed" check below to trigger. + // Instead we want to discard the failed attempt and redo the update. + let failed_target_matches = host + .status + .staged + .as_ref() + .and_then(|entry| entry.image.as_ref()) + .is_some_and(|image| image.image == target); + let discarded_failed_update = discard_failed_aboot_update(storage, &host)?; + if discarded_failed_update { + // Refresh host to skip discarded update host = get_composefs_status(storage, booted_cfs).await?; } + let retry_failed_target = discarded_failed_update && failed_target_matches; let new_spec = { let mut new_spec = host.spec.clone(); @@ -60,7 +72,7 @@ pub(crate) async fn switch_composefs( new_spec }; - if new_spec == host.spec { + if new_spec == host.spec && !retry_failed_target { println!("Image specification is unchanged."); if opts.apply && host.status.staged.is_some() { crate::reboot::reboot()?; diff --git a/tmt/tests/booted/test-aboot-update-rollback.py b/tmt/tests/booted/test-aboot-update-rollback.py index d9ec5b032..4dbe76648 100644 --- a/tmt/tests/booted/test-aboot-update-rollback.py +++ b/tmt/tests/booted/test-aboot-update-rollback.py @@ -10,9 +10,11 @@ # skip_if_ostree: true # try_bind_storage: true +import configparser import json import os from pathlib import Path +import shlex import subprocess from aboot_testlib import run, status, verify_boot @@ -23,6 +25,18 @@ ABOOT_STATE = Path("/sysroot/state/boot/aboot") +def fail_next_deploy(): + # Make aboot-deploy fail while reading its configuration, before flashing. + original_config = ABOOT_CONFIG.read_bytes() + ABOOT_CONFIG_BACKUP.write_bytes(original_config) + ABOOT_CONFIG.write_bytes(original_config + b"\nBOOTC_TEST_FAIL_DEPLOY=1\n") + + +def restore_deploy_config(): + ABOOT_CONFIG.write_bytes(ABOOT_CONFIG_BACKUP.read_bytes()) + ABOOT_CONFIG_BACKUP.unlink() + + def main(): print("TAP version 14\nukiboot update and rollback") count = int(os.environ.get("TMT_REBOOT_COUNT", "0")) @@ -37,14 +51,10 @@ def main(): assert staged["staged"]["composefs"]["bootType"] == "Aboot", staged assert staged["staged"]["composefs"]["bootloader"] == "ukiboot", staged assert staged["staged"]["composefs"]["verity"] != initial["booted"]["composefs"]["verity"], staged - # Make aboot-deploy fail while reading its configuration, before flashing. - original_config = ABOOT_CONFIG.read_bytes() - ABOOT_CONFIG_BACKUP.write_bytes(original_config) - ABOOT_CONFIG.write_bytes(original_config + b"\nBOOTC_TEST_FAIL_DEPLOY=1\n") + fail_next_deploy() subprocess.run(["tmt-reboot"], check=True) elif count == 1: - ABOOT_CONFIG.write_bytes(ABOOT_CONFIG_BACKUP.read_bytes()) - ABOOT_CONFIG_BACKUP.unlink() + restore_deploy_config() failed = verify_boot("a", 1, staged=True) attempted = (ABOOT_STATE / "attempted").read_bytes() pending = (ABOOT_STATE / "pending").read_bytes() @@ -65,8 +75,35 @@ def main(): assert retried["staged"]["image"]["image"]["image"] == target, retried assert retried["staged"]["composefs"]["verity"] == failed["staged"]["composefs"]["verity"], retried assert not (ABOOT_STATE / "attempted").exists() + # Fail this retry too, so switch can recover it on the next boot. + fail_next_deploy() subprocess.run(["tmt-reboot"], check=True) elif count == 3: + restore_deploy_config() + failed = verify_boot("a", 1, staged=True) + assert (ABOOT_STATE / "attempted").exists() + # Model a mutable-tag upgrade: the booted and failed deployments have + # the same source reference, but the source now resolves to version 2. + digest = failed["booted"]["composefs"]["verity"] + origin_path = Path(f"/sysroot/state/deploy/{digest}/{digest}.origin") + origin = configparser.ConfigParser(interpolation=None) + origin.read_string(origin_path.read_text()) + origin["origin"]["container-image-reference"] = ( + f"ostree-unverified-image:containers-storage:{target}" + ) + # bootc remounts /sysroot writable only in its own mount namespace. + run("mount", "-o", "remount,rw", "/sysroot") + with origin_path.open("w") as stream: + origin.write(stream) + failed = status() + assert failed["booted"]["image"]["image"] == failed["staged"]["image"]["image"], failed + assert failed["booted"]["image"]["imageDigest"] != failed["staged"]["image"]["imageDigest"], failed + ORIGINAL.write_text(json.dumps(failed["booted"])) + command = shlex.join([ + "bootc", "switch", "--apply", "--transport", "containers-storage", target, + ]) + subprocess.run(["tmt-reboot", "-c", command], check=True) + elif count == 4: original = json.loads(ORIGINAL.read_text()) updated = verify_boot("b", 2) assert updated["booted"]["image"]["image"]["image"] == target, updated @@ -74,10 +111,16 @@ def main(): assert updated["rollback"]["image"] == original["image"], updated assert updated["rollback"]["composefs"]["verity"] == original["composefs"]["verity"], updated assert updated["rollback"]["composefs"]["bootloader"] == "ukiboot", updated + assert not (ABOOT_STATE / "attempted").exists() + assert not (ABOOT_STATE / "pending").exists() + # Without a failed attempt, switching to the same source stays a no-op. + output = run("bootc", "switch", "--transport", "containers-storage", target) + assert "Image specification is unchanged." in output, output + assert status()["staged"] is None run("bootc", "rollback") assert status()["rollbackQueued"] subprocess.run(["tmt-reboot"], check=True) - elif count == 4: + elif count == 5: original = json.loads(ORIGINAL.read_text()) restored = verify_boot("a", 1) assert restored["booted"]["image"] == original["image"], restored