diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index a0c93d0d1..f4b5fdb5b 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}; @@ -43,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, @@ -51,6 +53,42 @@ 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<()> { + 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)] @@ -356,18 +394,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!( @@ -567,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)?; @@ -758,15 +840,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")?, @@ -778,6 +859,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/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 155bd6dce..26cc81dbb 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2217,7 +2217,7 @@ fn install_ukiboot( Ok(()) } -fn validate_bootloader( +pub(crate) fn validate_bootloader( entry: &ComposefsBootEntry, bootloader: Bootloader, ) -> Result<()> { @@ -2596,6 +2596,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/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/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 3a580a628..7cc05ddea 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")?; @@ -49,6 +49,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 8e42186b0..1e0873e43 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -268,6 +268,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, @@ -372,13 +381,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 @@ -386,6 +398,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") @@ -561,11 +577,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..c38db873a 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): @@ -29,8 +29,9 @@ def verify_boot(slot, version): 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 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..d9ec5b032 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(): @@ -32,19 +35,49 @@ 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() + 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 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) - elif count == 2: + elif count == 4: original = json.loads(ORIGINAL.read_text()) restored = verify_boot("a", 1) assert restored["booted"]["image"] == original["image"], restored